feat(landscape): HTTP API, worker consumer, wiring, docs
Adds the /landscape routes (slots, slot upload, runs, run detail, results), mounts them in the router, wires the landscape repository, job publisher, godal raster IO and Compute into the composition root, and registers the gis-landscape-runner consumer in the worker. Also fixes the Dockerfile so the image actually runs: godal's cgo needs g++, and the runtime base must match the builder's Alpine release or libgdal's soname does not resolve. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
170
internal/transport/http/landscape_handler.go
Normal file
170
internal/transport/http/landscape_handler.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gis/internal/landscape"
|
||||
"gis/internal/service"
|
||||
"gis/pkg/httputil"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// LandscapeHandler serves the /landscape routes.
|
||||
type LandscapeHandler struct {
|
||||
svc *service.LandscapeService
|
||||
validate *validator.Validate
|
||||
}
|
||||
|
||||
// NewLandscapeHandler returns a LandscapeHandler.
|
||||
func NewLandscapeHandler(svc *service.LandscapeService, validate *validator.Validate) *LandscapeHandler {
|
||||
return &LandscapeHandler{svc: svc, validate: validate}
|
||||
}
|
||||
|
||||
// Register mounts the Landscape Program routes on r.
|
||||
func (h *LandscapeHandler) Register(r chi.Router) {
|
||||
r.Get("/slots", h.slots)
|
||||
r.Post("/slots/{code}", h.uploadSlot)
|
||||
r.Get("/runs", h.listRuns)
|
||||
r.Post("/runs", h.createRun)
|
||||
r.Get("/runs/{id}", h.getRun)
|
||||
r.Get("/results", h.results)
|
||||
}
|
||||
|
||||
// runRequest is the body of POST /landscape/runs. Overrides are decoded as
|
||||
// strings so a malformed id is a 422 instead of a 400 on the whole body.
|
||||
type runRequest struct {
|
||||
FromStage *int `json:"from_stage" validate:"omitempty,gte=0,lte=3"`
|
||||
Overrides map[string]string `json:"overrides" validate:"omitempty,dive,uuid"`
|
||||
Params *landscape.Params `json:"params"`
|
||||
}
|
||||
|
||||
// slots lists every leaf input slot with the dataset currently filling it.
|
||||
func (h *LandscapeHandler) slots(w http.ResponseWriter, r *http.Request) {
|
||||
slots, err := h.svc.Slots(r.Context())
|
||||
if err != nil {
|
||||
respondDomainError(w, err)
|
||||
return
|
||||
}
|
||||
httputil.WriteJSON(w, http.StatusOK, slots)
|
||||
}
|
||||
|
||||
// uploadSlot stores a raster in the slot's dataset group under the landscape
|
||||
// category. An unknown slot code yields 404.
|
||||
func (h *LandscapeHandler) uploadSlot(w http.ResponseWriter, r *http.Request) {
|
||||
code := chi.URLParam(r, "code")
|
||||
|
||||
if err := r.ParseMultipartForm(maxUploadBytes); err != nil {
|
||||
httputil.WriteError(w, http.StatusBadRequest, "request must be multipart/form-data")
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
httputil.WriteError(w, http.StatusBadRequest, "file is required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
contentType := header.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "image/tiff"
|
||||
}
|
||||
|
||||
dataset, err := h.svc.UploadToSlot(r.Context(), code, service.SlotUploadInput{
|
||||
Filename: header.Filename,
|
||||
ContentType: contentType,
|
||||
Size: header.Size,
|
||||
Reader: file,
|
||||
Name: r.FormValue("name"),
|
||||
StartDate: optionalFormValue(r, "start_date"),
|
||||
EndDate: optionalFormValue(r, "end_date"),
|
||||
})
|
||||
if err != nil {
|
||||
respondDomainError(w, err)
|
||||
return
|
||||
}
|
||||
httputil.WriteJSON(w, http.StatusCreated, dataset)
|
||||
}
|
||||
|
||||
// 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")
|
||||
return
|
||||
}
|
||||
if err := h.validate.Struct(req); err != nil {
|
||||
httputil.WriteValidationErrors(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
overrides := make(map[string]uuid.UUID, len(req.Overrides))
|
||||
for group, raw := range req.Overrides {
|
||||
id, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
httputil.WriteError(w, http.StatusUnprocessableEntity, "overrides["+group+"] must be a valid UUID")
|
||||
return
|
||||
}
|
||||
overrides[group] = id
|
||||
}
|
||||
|
||||
in := service.RunInput{FromStage: req.FromStage, Overrides: overrides}
|
||||
if req.Params != nil {
|
||||
in.Params = *req.Params
|
||||
}
|
||||
|
||||
run, err := h.svc.CreateRun(r.Context(), in)
|
||||
if err != nil {
|
||||
respondDomainError(w, err)
|
||||
return
|
||||
}
|
||||
httputil.WriteJSON(w, http.StatusAccepted, run)
|
||||
}
|
||||
|
||||
// listRuns returns a page of runs, newest first.
|
||||
func (h *LandscapeHandler) listRuns(w http.ResponseWriter, r *http.Request) {
|
||||
page, ok := parsePositiveIntQuery(w, r, "page", 1)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pageSize, ok := parsePositiveIntQuery(w, r, "page_size", service.DefaultPageSize)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.svc.ListRuns(r.Context(), page, pageSize)
|
||||
if err != nil {
|
||||
respondDomainError(w, err)
|
||||
return
|
||||
}
|
||||
httputil.WriteJSON(w, http.StatusOK, newPaginated(result.Items, result.Page, result.PageSize, result.Total))
|
||||
}
|
||||
|
||||
// getRun returns one run with its per-node execution state.
|
||||
func (h *LandscapeHandler) getRun(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseUUIDParam(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
run, err := h.svc.GetRun(r.Context(), id)
|
||||
if err != nil {
|
||||
respondDomainError(w, err)
|
||||
return
|
||||
}
|
||||
httputil.WriteJSON(w, http.StatusOK, run)
|
||||
}
|
||||
|
||||
// results lists every output group with its freshest ready dataset and the URLs
|
||||
// to render it.
|
||||
func (h *LandscapeHandler) results(w http.ResponseWriter, r *http.Request) {
|
||||
results, err := h.svc.Results(r.Context())
|
||||
if err != nil {
|
||||
respondDomainError(w, err)
|
||||
return
|
||||
}
|
||||
httputil.WriteJSON(w, http.StatusOK, results)
|
||||
}
|
||||
120
internal/transport/http/landscape_handler_test.go
Normal file
120
internal/transport/http/landscape_handler_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
// newLandscapeTestHandler builds a handler with a nil service: every case below
|
||||
// is rejected while parsing or validating the request, before the service is
|
||||
// reached.
|
||||
func newLandscapeTestHandler() *LandscapeHandler {
|
||||
return NewLandscapeHandler(nil, validator.New(validator.WithRequiredStructEnabled()))
|
||||
}
|
||||
|
||||
func postRun(t *testing.T, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, "/landscape/runs", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
newLandscapeTestHandler().createRun(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestCreateRunInvalidJSON(t *testing.T) {
|
||||
rec := postRun(t, `{"from_stage":`)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d (body %s)", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRunUnknownField(t *testing.T) {
|
||||
// DecodeJSON rejects unknown fields, so a typo is a 400 rather than a
|
||||
// silently ignored option.
|
||||
rec := postRun(t, `{"from_stages":1}`)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d (body %s)", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRunFromStageOutOfRange(t *testing.T) {
|
||||
rec := postRun(t, `{"from_stage":7}`)
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("status = %d, want %d (body %s)", rec.Code, http.StatusUnprocessableEntity, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "FromStage") {
|
||||
t.Fatalf("body %s does not name the offending field", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRunOverrideNotUUID(t *testing.T) {
|
||||
rec := postRun(t, `{"overrides":{"lp-z-lcr":"not-a-uuid"}}`)
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("status = %d, want %d (body %s)", rec.Code, http.StatusUnprocessableEntity, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadSlotRequiresMultipart(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/landscape/slots/lp-z-lcr", strings.NewReader(`{}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
newLandscapeTestHandler().uploadSlot(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d (body %s)", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRunsInvalidPage(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/landscape/runs?page=0", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
newLandscapeTestHandler().listRuns(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d (body %s)", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRunInvalidID(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/landscape/runs/nope", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
newLandscapeTestHandler().getRun(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d (body %s)", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
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},
|
||||
}
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,9 @@ type RouterDeps struct {
|
||||
Datasets *DatasetHandler
|
||||
// DatasetGroups serves the /dataset-groups routes.
|
||||
DatasetGroups *DatasetGroupHandler
|
||||
OpenAPISpec []byte
|
||||
// Landscape serves the /landscape routes.
|
||||
Landscape *LandscapeHandler
|
||||
OpenAPISpec []byte
|
||||
}
|
||||
|
||||
// NewRouter builds the application's HTTP handler with middleware and routes.
|
||||
@@ -40,6 +42,7 @@ func NewRouter(deps RouterDeps) http.Handler {
|
||||
r.Route("/categories", deps.Categories.Register)
|
||||
r.Route("/datasets", deps.Datasets.Register)
|
||||
r.Route("/dataset-groups", deps.DatasetGroups.Register)
|
||||
r.Route("/landscape", deps.Landscape.Register)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user