fix(landscape): accept an empty run body; pin the builder's Alpine release
POST /landscape/runs documents an optional body, but DecodeJSON returned io.EOF on an empty one and the handler turned that into 400 — breaking the most common call, an all-defaults run. decodeRunRequest now treats io.EOF as the zero request; a malformed body is still 400. The Dockerfile's Alpine ABI coupling was enforced only by a comment: a floating golang:1.26-alpine builder against a pinned alpine:3.24 runtime would build green and die at startup on a libgdal soname mismatch as soon as the Go image rolls forward. Both stages are now pinned to 3.24. Also: the route-mount test walks the router so all six routes are asserted (GET /slots and GET /results included), and the spec documents the 404 those two return when migration 00011's seeded groups are absent. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -761,6 +761,10 @@ paths:
|
|||||||
Every leaf input group of the computation graph, in graph order, with
|
Every leaf input group of the computation graph, in graph order, with
|
||||||
the dataset currently filling it (any status) or `null` when nothing
|
the dataset currently filling it (any status) or `null` when nothing
|
||||||
has been uploaded yet.
|
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:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: The input slots
|
description: The input slots
|
||||||
@@ -769,6 +773,7 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: array
|
type: array
|
||||||
items: { $ref: "#/components/schemas/LandscapeSlot" }
|
items: { $ref: "#/components/schemas/LandscapeSlot" }
|
||||||
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
|
||||||
/landscape/slots/{code}:
|
/landscape/slots/{code}:
|
||||||
parameters:
|
parameters:
|
||||||
@@ -842,6 +847,9 @@ paths:
|
|||||||
Validates the params and overrides, persists the run with one pending
|
Validates the params and overrides, persists the run with one pending
|
||||||
node per graph node, and publishes the worker job. Returns 202: the
|
node per graph node, and publishes the worker job. Returns 202: the
|
||||||
graph is executed asynchronously, poll `GET /landscape/runs/{id}`.
|
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:
|
requestBody:
|
||||||
required: false
|
required: false
|
||||||
content:
|
content:
|
||||||
@@ -879,6 +887,10 @@ paths:
|
|||||||
One entry per output group of the graph with its freshest `ready`
|
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
|
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.
|
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:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: The result layers
|
description: The result layers
|
||||||
@@ -887,6 +899,7 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: array
|
type: array
|
||||||
items: { $ref: "#/components/schemas/LandscapeResult" }
|
items: { $ref: "#/components/schemas/LandscapeResult" }
|
||||||
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
|
||||||
components:
|
components:
|
||||||
parameters:
|
parameters:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Build context is the repo root: docker build -f build/package/Dockerfile .
|
# 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.
|
# 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.
|
# g++ is required: parts of the binding compile as C++ against GDAL's headers.
|
||||||
@@ -13,8 +13,11 @@ RUN go mod download
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN CGO_ENABLED=1 GOOS=linux go build -trimpath -o /out/gis ./cmd/gis
|
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):
|
# Must stay on the same Alpine release as the builder — hence the pinned
|
||||||
# an older base ships a different libgdal soname and the binary will not start.
|
# `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
|
FROM alpine:3.24
|
||||||
|
|
||||||
# gdal: shared library for godal (raster math in the worker).
|
# gdal: shared library for godal (raster math in the worker).
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"gis/internal/landscape"
|
"gis/internal/landscape"
|
||||||
@@ -89,12 +91,26 @@ func (h *LandscapeHandler) uploadSlot(w http.ResponseWriter, r *http.Request) {
|
|||||||
httputil.WriteJSON(w, http.StatusCreated, dataset)
|
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;
|
// createRun queues a Landscape Program run and returns it with 202 Accepted;
|
||||||
// the worker executes it asynchronously.
|
// the worker executes it asynchronously.
|
||||||
func (h *LandscapeHandler) createRun(w http.ResponseWriter, r *http.Request) {
|
func (h *LandscapeHandler) createRun(w http.ResponseWriter, r *http.Request) {
|
||||||
req, err := httputil.DecodeJSON[runRequest](w, r)
|
req, ok := decodeRunRequest(w, r)
|
||||||
if err != nil {
|
if !ok {
|
||||||
httputil.WriteError(w, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.validate.Struct(req); err != nil {
|
if err := h.validate.Struct(req); err != nil {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package http
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -90,31 +91,101 @@ func TestGetRunInvalidID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRegisterMountsRoutes checks the route table itself: every path reaches a
|
// TestDecodeRunRequestEmptyBody pins the "all defaults" call: POST
|
||||||
// handler (405/404 would mean it is not mounted). Each request is one that is
|
// /landscape/runs with no body at all is a valid request, not a 400. Tested at
|
||||||
// rejected before the service is used.
|
// 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) {
|
func TestRegisterMountsRoutes(t *testing.T) {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Route("/landscape", newLandscapeTestHandler().Register)
|
r.Route("/landscape", newLandscapeTestHandler().Register)
|
||||||
|
|
||||||
cases := []struct {
|
got := map[string]bool{}
|
||||||
method, path, body, contentType string
|
err := chi.Walk(r, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
|
||||||
want int
|
got[method+" "+strings.TrimSuffix(route, "/")] = true
|
||||||
}{
|
return nil
|
||||||
{http.MethodPost, "/landscape/runs", `{`, "application/json", http.StatusBadRequest},
|
})
|
||||||
{http.MethodPost, "/landscape/slots/lp-z-lcr", `{}`, "application/json", http.StatusBadRequest},
|
if err != nil {
|
||||||
{http.MethodGet, "/landscape/runs?page=0", "", "", http.StatusBadRequest},
|
t.Fatalf("walk routes: %v", err)
|
||||||
{http.MethodGet, "/landscape/runs/nope", "", "", http.StatusBadRequest},
|
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
|
||||||
req := httptest.NewRequest(c.method, c.path, strings.NewReader(c.body))
|
want := []string{
|
||||||
if c.contentType != "" {
|
"GET /landscape/slots",
|
||||||
req.Header.Set("Content-Type", c.contentType)
|
"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()
|
rec := httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
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())
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user