internal/jobs persists jobs in the jobs table, which is also the queue: a
bounded pool of workers claims queued jobs oldest first. Handlers are
registered per kind before Start with typed payloads and results, and can
publish partial results to the row while they run. Only typed results and
*jobs.Failure messages are stored; any other handler error is logged and
stored as "internal error", so dependency error text cannot surface a
secret. Start fails jobs a previous process left running with
"interrupted by restart" before any worker runs.
GET /api/v1/jobs/{id} returns {id, kind, status, result, error} inside the
protected /api/v1 group; an unknown id is a 404 not_found.
Migration 0003_jobs_status rebuilds jobs with status queued, running,
succeeded, failed, mapping legacy pending and done rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
120 lines
4.2 KiB
Go
120 lines
4.2 KiB
Go
// Package server builds the MealQuest HTTP router.
|
|
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
|
)
|
|
|
|
// reservedPrefixes are owned by the API router and are never answered with the
|
|
// SPA's index.html.
|
|
var reservedPrefixes = []string{"/api", "/auth", "/media", "/health"}
|
|
|
|
// RouteMounter registers its own routes on the router.
|
|
type RouteMounter interface {
|
|
Mount(r chi.Router)
|
|
}
|
|
|
|
// NewRouter returns the HTTP handler for the service. Every request first
|
|
// has its client address and scheme resolved, honouring forwarded headers
|
|
// only from a peer in trusted (see resolveOrigin). version is reported by
|
|
// GET /health; auth mounts the /auth routes; sessions guards /api/v1 and
|
|
// /media; settings serves /api/v1/settings/household; runner serves
|
|
// /api/v1/jobs/{id}; assets is the web build
|
|
// served for every other GET. /health, /auth and the web build are public.
|
|
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
|
|
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, runner *jobs.Runner, assets fs.FS) (http.Handler, error) {
|
|
if settings == nil {
|
|
return nil, errors.New("server: nil household settings service")
|
|
}
|
|
if runner == nil {
|
|
return nil, errors.New("server: nil job runner")
|
|
}
|
|
notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found")
|
|
methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
|
|
|
web, err := frontend.NewHandler(assets, logger, notFound, reservedPrefixes...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
r := chi.NewRouter()
|
|
r.Use(resolveOrigin(logger, trusted))
|
|
r.MethodNotAllowed(methodNotAllowed)
|
|
r.NotFound(notFound)
|
|
|
|
r.Get("/health", health(logger, version))
|
|
auth.Mount(r)
|
|
// The session middleware wraps the whole /api/v1 and /media subrouters
|
|
// rather than being added with Use: chi skips a subrouter's middleware
|
|
// when it has no routes, which would leave it unguarded.
|
|
signedIn := requireSession(logger, sessions)
|
|
csrf := requireCSRF(logger)
|
|
apiV1, media := chi.NewRouter(), chi.NewRouter()
|
|
for _, sub := range []*chi.Mux{apiV1, media} {
|
|
sub.MethodNotAllowed(methodNotAllowed)
|
|
sub.NotFound(notFound)
|
|
}
|
|
apiV1.Get("/me", me(logger))
|
|
apiV1.Get("/settings/household", getHouseholdSettings(logger, settings))
|
|
apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings))
|
|
apiV1.Get("/jobs/{id}", getJob(logger, runner))
|
|
r.Route("/api", func(r chi.Router) {
|
|
r.MethodNotAllowed(methodNotAllowed)
|
|
r.NotFound(notFound)
|
|
r.Mount("/v1", signedIn(csrf(apiV1)))
|
|
})
|
|
r.Mount("/media", signedIn(media))
|
|
// Catch-all for the PWA. Unknown /auth paths reach the frontend handler,
|
|
// which passes them to notFound rather than the SPA fallback.
|
|
r.Method(http.MethodGet, "/*", web)
|
|
r.Method(http.MethodHead, "/*", web)
|
|
return r, nil
|
|
}
|
|
|
|
type errorBody struct {
|
|
Error apiError `json:"error"`
|
|
}
|
|
|
|
type apiError struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
Retryable bool `json:"retryable"`
|
|
}
|
|
|
|
// apiErrorHandler responds with status and a fixed, non-retryable API error.
|
|
func apiErrorHandler(logger *slog.Logger, status int, code, message string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
writeError(w, r, logger, status, code, message)
|
|
}
|
|
}
|
|
|
|
type healthResponse struct {
|
|
Status string `json:"status"`
|
|
Version string `json:"version"`
|
|
}
|
|
|
|
func health(logger *slog.Logger, version string) http.HandlerFunc {
|
|
body, err := json.Marshal(healthResponse{Status: "ok", Version: version})
|
|
if err != nil {
|
|
panic(err) // two string fields cannot fail to marshal
|
|
}
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
if _, err := w.Write(body); err != nil {
|
|
logger.WarnContext(r.Context(), "write health response", "error", err)
|
|
}
|
|
}
|
|
}
|