diff --git a/api/openapi.yaml b/api/openapi.yaml index eb9d563..1badecb 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -761,6 +761,10 @@ paths: Every leaf input group of the computation graph, in graph order, with the dataset currently filling it (any status) or `null` when nothing has been uploaded yet. + + Yields 404 when a slot's dataset group is missing — that means + migration `00011_landscape.sql`, which seeds the groups, has not been + applied to this database. responses: "200": description: The input slots @@ -769,6 +773,7 @@ paths: schema: type: array items: { $ref: "#/components/schemas/LandscapeSlot" } + "404": { $ref: "#/components/responses/NotFound" } /landscape/slots/{code}: parameters: @@ -842,6 +847,9 @@ paths: Validates the params and overrides, persists the run with one pending node per graph node, and publishes the worker job. Returns 202: the graph is executed asynchronously, poll `GET /landscape/runs/{id}`. + + Every field has a default, so the body is optional: sending no body at + all (or `{}`) starts a default incremental run. requestBody: required: false content: @@ -879,6 +887,10 @@ paths: One entry per output group of the graph with its freshest `ready` raster, the COG URL to render it, and the group's SLD URL when one has been uploaded. `latest` is `null` while nothing has been computed. + + Yields 404 when an output group is missing — that means migration + `00011_landscape.sql`, which seeds the groups, has not been applied to + this database. responses: "200": description: The result layers @@ -887,6 +899,7 @@ paths: schema: type: array items: { $ref: "#/components/schemas/LandscapeResult" } + "404": { $ref: "#/components/responses/NotFound" } components: parameters: diff --git a/build/package/Dockerfile b/build/package/Dockerfile index 56cec4b..55c6def 100644 --- a/build/package/Dockerfile +++ b/build/package/Dockerfile @@ -1,5 +1,5 @@ # Build context is the repo root: docker build -f build/package/Dockerfile . -FROM golang:1.26-alpine AS builder +FROM golang:1.26-alpine3.24 AS builder # godal (GDAL bindings) needs cgo, a C/C++ toolchain and the GDAL headers. # g++ is required: parts of the binding compile as C++ against GDAL's headers. @@ -13,8 +13,11 @@ RUN go mod download COPY . . RUN CGO_ENABLED=1 GOOS=linux go build -trimpath -o /out/gis ./cmd/gis -# Must match the builder's Alpine release (golang:1.26-alpine is Alpine 3.24): -# an older base ships a different libgdal soname and the binary will not start. +# Must stay on the same Alpine release as the builder — hence the pinned +# `golang:1.26-alpine3.24` above rather than the floating `-alpine` tag. A +# mismatch ships a different libgdal soname, and the image still builds green +# while the binary dies at startup with "libgdal.so.NN: No such file". +# Bump both lines together. FROM alpine:3.24 # gdal: shared library for godal (raster math in the worker). diff --git a/internal/transport/http/landscape_handler.go b/internal/transport/http/landscape_handler.go index 180df9f..846e597 100644 --- a/internal/transport/http/landscape_handler.go +++ b/internal/transport/http/landscape_handler.go @@ -1,6 +1,8 @@ package http import ( + "errors" + "io" "net/http" "gis/internal/landscape" @@ -89,12 +91,26 @@ func (h *LandscapeHandler) uploadSlot(w http.ResponseWriter, r *http.Request) { httputil.WriteJSON(w, http.StatusCreated, dataset) } +// decodeRunRequest reads the run body. The whole body is optional — every field +// has a default — so an empty body is the zero request (a default incremental +// run), not an error. A malformed body writes a 400 and reports false. +func decodeRunRequest(w http.ResponseWriter, r *http.Request) (runRequest, bool) { + req, err := httputil.DecodeJSON[runRequest](w, r) + switch { + case errors.Is(err, io.EOF): + return runRequest{}, true + case err != nil: + httputil.WriteError(w, http.StatusBadRequest, "invalid request body") + return runRequest{}, false + } + return req, true +} + // createRun queues a Landscape Program run and returns it with 202 Accepted; // the worker executes it asynchronously. func (h *LandscapeHandler) createRun(w http.ResponseWriter, r *http.Request) { - req, err := httputil.DecodeJSON[runRequest](w, r) - if err != nil { - httputil.WriteError(w, http.StatusBadRequest, "invalid request body") + req, ok := decodeRunRequest(w, r) + if !ok { return } if err := h.validate.Struct(req); err != nil { diff --git a/internal/transport/http/landscape_handler_test.go b/internal/transport/http/landscape_handler_test.go index 6ed979c..2b07c62 100644 --- a/internal/transport/http/landscape_handler_test.go +++ b/internal/transport/http/landscape_handler_test.go @@ -3,6 +3,7 @@ package http import ( "net/http" "net/http/httptest" + "sort" "strings" "testing" @@ -90,31 +91,101 @@ func TestGetRunInvalidID(t *testing.T) { } } -// TestRegisterMountsRoutes checks the route table itself: every path reaches a -// handler (405/404 would mean it is not mounted). Each request is one that is -// rejected before the service is used. +// TestDecodeRunRequestEmptyBody pins the "all defaults" call: POST +// /landscape/runs with no body at all is a valid request, not a 400. Tested at +// the helper level because the 202 path needs a live service. +func TestDecodeRunRequestEmptyBody(t *testing.T) { + for _, body := range []string{"", "{}"} { + req := httptest.NewRequest(http.MethodPost, "/landscape/runs", strings.NewReader(body)) + rec := httptest.NewRecorder() + + got, ok := decodeRunRequest(rec, req) + if !ok { + t.Fatalf("body %q: decodeRunRequest reported failure with status %d", body, rec.Code) + } + if got.FromStage != nil || len(got.Overrides) != 0 || got.Params != nil { + t.Fatalf("body %q: got %+v, want the zero request", body, got) + } + if rec.Code != http.StatusOK || rec.Body.Len() != 0 { + t.Fatalf("body %q: helper wrote a response (status %d, body %s)", body, rec.Code, rec.Body.String()) + } + // The zero request must also survive validation, or an empty body + // would still be rejected one step later. + if err := validator.New(validator.WithRequiredStructEnabled()).Struct(got); err != nil { + t.Fatalf("body %q: zero request fails validation: %v", body, err) + } + } +} + +func TestDecodeRunRequestMalformedBody(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/landscape/runs", strings.NewReader(`{`)) + rec := httptest.NewRecorder() + + if _, ok := decodeRunRequest(rec, req); ok { + t.Fatal("decodeRunRequest accepted a truncated body") + } + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } +} + +// TestRegisterMountsRoutes walks the route table of a router with the handler +// registered, so every one of the six routes is checked — including GET /slots +// and GET /results, which cannot be exercised without a live service. func TestRegisterMountsRoutes(t *testing.T) { r := chi.NewRouter() r.Route("/landscape", newLandscapeTestHandler().Register) - cases := []struct { - method, path, body, contentType string - want int - }{ - {http.MethodPost, "/landscape/runs", `{`, "application/json", http.StatusBadRequest}, - {http.MethodPost, "/landscape/slots/lp-z-lcr", `{}`, "application/json", http.StatusBadRequest}, - {http.MethodGet, "/landscape/runs?page=0", "", "", http.StatusBadRequest}, - {http.MethodGet, "/landscape/runs/nope", "", "", http.StatusBadRequest}, + got := map[string]bool{} + err := chi.Walk(r, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { + got[method+" "+strings.TrimSuffix(route, "/")] = true + return nil + }) + if err != nil { + t.Fatalf("walk routes: %v", err) } - for _, c := range cases { - req := httptest.NewRequest(c.method, c.path, strings.NewReader(c.body)) - if c.contentType != "" { - req.Header.Set("Content-Type", c.contentType) - } - rec := httptest.NewRecorder() - r.ServeHTTP(rec, req) - if rec.Code != c.want { - t.Errorf("%s %s: status = %d, want %d (body %s)", c.method, c.path, rec.Code, c.want, rec.Body.String()) + + want := []string{ + "GET /landscape/slots", + "POST /landscape/slots/{code}", + "GET /landscape/runs", + "POST /landscape/runs", + "GET /landscape/runs/{id}", + "GET /landscape/results", + } + for _, w := range want { + if !got[w] { + t.Errorf("route %q is not mounted; mounted routes: %v", w, keys(got)) } } + if len(got) != len(want) { + t.Errorf("mounted %d routes, want %d: %v", len(got), len(want), keys(got)) + } +} + +// TestRoutedRequestReachesHandler sends a real request through the router so the +// URL parameter wiring (chi.URLParam) is exercised, not just the pattern. +func TestRoutedRequestReachesHandler(t *testing.T) { + r := chi.NewRouter() + r.Route("/landscape", newLandscapeTestHandler().Register) + + req := httptest.NewRequest(http.MethodGet, "/landscape/runs/nope", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d (body %s)", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "invalid id") { + t.Fatalf("body %s does not report the bad id", rec.Body.String()) + } +} + +func keys(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out }