From e9f36f509e41edf967ff909995f63a54cf3e1e17 Mon Sep 17 00:00:00 2001 From: Justin Visser Date: Wed, 10 Jun 2026 13:03:12 +0200 Subject: [PATCH] api: JSON API over the engine; server mode in cmd/spreadlab internal/api stays a translation layer: decode (with DisallowUnknownFields so typos 400 instead of silently defaulting), run the engine, encode; all validation stays in the engine. Routes use Go 1.22 mux patterns ('POST /api/comparison'), so wrong methods get 405 from the stdlib for free. httptest runs handlers fully in memory; the API test re-pins the 99/70/7 golden values end to end. ComparisonResponse is added to tygo.yaml with a type mapping so the generated web/src/types/api.ts reuses the engine's TS types. cmd/spreadlab now serves on -addr (default localhost:8080); -table keeps the CLI comparison as a sanity check. --- cmd/spreadlab/main.go | 30 ++++++++--- internal/api/api.go | 80 +++++++++++++++++++++++++++++ internal/api/api_test.go | 108 +++++++++++++++++++++++++++++++++++++++ tygo.yaml | 9 ++++ web/src/types/api.ts | 20 ++++++++ 5 files changed, 239 insertions(+), 8 deletions(-) create mode 100644 internal/api/api.go create mode 100644 internal/api/api_test.go create mode 100644 web/src/types/api.ts diff --git a/cmd/spreadlab/main.go b/cmd/spreadlab/main.go index 62266ac..13429cd 100644 --- a/cmd/spreadlab/main.go +++ b/cmd/spreadlab/main.go @@ -1,23 +1,37 @@ -// Command spreadlab will serve the spreadlab dashboard. Until the HTTP -// server lands (milestone 2), it runs the prototype's three scenarios in -// the default world and prints the comparison. +// Command spreadlab serves the spreadlab API (and, from milestone 4, the +// dashboard itself). The -table flag instead prints the three-scenario +// comparison and exits, a quick engine sanity check. package main import ( + "flag" "fmt" "io" + "log" + "net/http" "os" "strings" + "github.com/JustinZeus/spreadlab/internal/api" "github.com/JustinZeus/spreadlab/internal/engine" ) -// main stays a thin shell: the work lives in run, which takes its output -// as an io.Writer so tests can capture it. func main() { - if err := run(os.Stdout); err != nil { - fmt.Fprintln(os.Stderr, "spreadlab:", err) - os.Exit(1) + addr := flag.String("addr", "localhost:8080", "address to serve the API on") + table := flag.Bool("table", false, "print the three-scenario comparison and exit") + flag.Parse() + + if *table { + if err := run(os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, "spreadlab:", err) + os.Exit(1) + } + return + } + + log.Printf("spreadlab API listening on http://%s", *addr) + if err := http.ListenAndServe(*addr, api.NewServer()); err != nil { + log.Fatal(err) } } diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..11d7107 --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,80 @@ +// Package api exposes the engine over HTTP as a small JSON API. It stays +// thin on purpose: decode, run the engine, encode. All validation lives in +// the engine; the API only translates errors into status codes. +package api + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/JustinZeus/spreadlab/internal/engine" +) + +// ComparisonResponse bundles what the dashboard needs to render one +// comparison: the config that was run, echoed back so frontend state stays +// honest, and one result per strategy. +type ComparisonResponse struct { + Config engine.Config `json:"config"` + Results []engine.Result `json:"results"` +} + +// errorResponse is the JSON shape of every non-2xx body. +type errorResponse struct { + Error string `json:"error"` +} + +// NewServer returns the API as an http.Handler. Routes use the Go 1.22+ +// pattern syntax, method and path in one string; the stdlib answers 405 +// for wrong methods on a known path by itself. +func NewServer() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/config/default", handleDefaultConfig) + mux.HandleFunc("POST /api/comparison", handleComparison) + return mux +} + +func handleDefaultConfig(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, engine.DefaultConfig()) +} + +func handleComparison(w http.ResponseWriter, r *http.Request) { + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() // a typo in a field name fails loudly + var config engine.Config + if err := decoder.Decode(&config); err != nil { + writeError(w, http.StatusBadRequest, fmt.Errorf("invalid config: %w", err)) + return + } + + strategies := engine.AllStrategies() + results := make([]engine.Result, 0, len(strategies)) + for _, strategy := range strategies { + result, err := engine.RunScenario(config, strategy) + if err != nil { + // The engine only errors on bad parameter values, which is + // the client's mistake, not the server's. + writeError(w, http.StatusBadRequest, err) + return + } + results = append(results, result) + } + writeJSON(w, http.StatusOK, ComparisonResponse{Config: config, Results: results}) +} + +// writeJSON marshals first and writes after, so an encoding failure can +// still become a clean 500 instead of a half-written body. +func writeJSON(w http.ResponseWriter, status int, payload any) { + body, err := json.Marshal(payload) + if err != nil { + http.Error(w, "encoding response failed", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(body) // a failed write means the client went away +} + +func writeError(w http.ResponseWriter, status int, err error) { + writeJSON(w, status, errorResponse{Error: err.Error()}) +} diff --git a/internal/api/api_test.go b/internal/api/api_test.go new file mode 100644 index 0000000..e235aaf --- /dev/null +++ b/internal/api/api_test.go @@ -0,0 +1,108 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/JustinZeus/spreadlab/internal/engine" +) + +// httptest exercises handlers fully in memory: no port, no network, just +// a recorded response to assert on. + +func serve(t *testing.T, method, path string, body []byte) *httptest.ResponseRecorder { + t.Helper() + request := httptest.NewRequest(method, path, bytes.NewReader(body)) + recorder := httptest.NewRecorder() + NewServer().ServeHTTP(recorder, request) + return recorder +} + +func TestDefaultConfigEndpoint(t *testing.T) { + recorder := serve(t, http.MethodGet, "/api/config/default", nil) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK) + } + var config engine.Config + if err := json.Unmarshal(recorder.Body.Bytes(), &config); err != nil { + t.Fatal(err) + } + if config != engine.DefaultConfig() { + t.Errorf("served config %+v, want %+v", config, engine.DefaultConfig()) + } +} + +func TestComparisonEndpointMatchesGoldenValues(t *testing.T) { + body, err := json.Marshal(engine.DefaultConfig()) + if err != nil { + t.Fatal(err) + } + recorder := serve(t, http.MethodPost, "/api/comparison", body) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body: %s", recorder.Code, http.StatusOK, recorder.Body) + } + var comparison ComparisonResponse + if err := json.Unmarshal(recorder.Body.Bytes(), &comparison); err != nil { + t.Fatal(err) + } + + // The same golden values the engine tests pin; the API must not + // change them in transit. + wantReached := map[engine.Strategy]int{ + engine.StrategyNone: 99, + engine.StrategyRandom: 70, + engine.StrategyMostConnected: 7, + } + if len(comparison.Results) != len(wantReached) { + t.Fatalf("got %d results, want %d", len(comparison.Results), len(wantReached)) + } + for _, result := range comparison.Results { + if want := wantReached[result.Strategy]; result.NumReached != want { + t.Errorf("%s: NumReached = %d, want %d", result.Strategy, result.NumReached, want) + } + } +} + +func TestComparisonEndpointRejectsBadRequests(t *testing.T) { + invalidValues, err := json.Marshal(func() engine.Config { + config := engine.DefaultConfig() + config.ForwardProb = 2.0 + return config + }()) + if err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + body []byte + }{ + {name: "not json", body: []byte("not json")}, + {name: "unknown field", body: []byte(`{"numStudentz": 5}`)}, + {name: "invalid parameter values", body: invalidValues}, + } + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + recorder := serve(t, http.MethodPost, "/api/comparison", testCase.body) + if recorder.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", recorder.Code, http.StatusBadRequest) + } + if !strings.Contains(recorder.Body.String(), `"error"`) { + t.Errorf("body %q is not an error response", recorder.Body) + } + }) + } + + t.Run("wrong method", func(t *testing.T) { + recorder := serve(t, http.MethodGet, "/api/comparison", nil) + if recorder.Code != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want %d", recorder.Code, http.StatusMethodNotAllowed) + } + }) +} diff --git a/tygo.yaml b/tygo.yaml index 80a40e1..7db3727 100644 --- a/tygo.yaml +++ b/tygo.yaml @@ -6,3 +6,12 @@ packages: output_path: "web/src/types/engine.ts" include_files: - "scenario.go" + - path: "github.com/JustinZeus/spreadlab/internal/api" + output_path: "web/src/types/api.ts" + include_files: + - "api.go" + frontmatter: | + import type { Config, Result } from "./engine"; + type_mappings: + engine.Config: "Config" + engine.Result: "Result" diff --git a/web/src/types/api.ts b/web/src/types/api.ts new file mode 100644 index 0000000..81f10f3 --- /dev/null +++ b/web/src/types/api.ts @@ -0,0 +1,20 @@ +// Code generated by tygo. DO NOT EDIT. +import type { Config, Result } from "./engine"; + +////////// +// source: api.go +/* +Package api exposes the engine over HTTP as a small JSON API. It stays +thin on purpose: decode, run the engine, encode. All validation lives in +the engine; the API only translates errors into status codes. +*/ + +/** + * ComparisonResponse bundles what the dashboard needs to render one + * comparison: the config that was run, echoed back so frontend state stays + * honest, and one result per strategy. + */ +export interface ComparisonResponse { + config: Config; + results: Result[]; +}