Creating a Regular now enqueues a ground_dinner job and returns at once
with its grounding_job_id. The job grounds the dinner's recipe_url with
GroundURL, else its name with Ground, and sets grounding_status grounded
(recipe, photo, method, minutes, difficulty) or failed with a
grounding_error reason code (migration 0009). It writes only a dinner
still pending, so a fix made meanwhile wins.
GET /api/v1/dinners/{id}/recipe-candidates returns up to 3 ranked
candidates {candidate_id, title, site_name, photo_url, minutes} without
downloading photos or running the model. POST /api/v1/dinners/{id}/ground
takes exactly one of {candidate_id}, {recipe_url} or {manual: {...}} and
sets grounded or manual.
PhotoCache takes an ImageFetcher interface and recipestest provides fakes
so other packages can test grounding with fake search, fetch and model.
main wires the Grounder with normalize_recipe over the AI settings.
Maestro task: 0cbaa5bf-8be4-4cbd-aab2-891e7f1bc2b5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
230 lines
8.1 KiB
Go
230 lines
8.1 KiB
Go
package server
|
|
|
|
import (
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
|
)
|
|
|
|
// maxDinnerBody bounds a POST or PATCH /dinners request body.
|
|
const maxDinnerBody = 16 << 10
|
|
|
|
// maxGroundBody bounds a POST /dinners/{id}/ground body, which may carry a
|
|
// manual recipe of dinners.MaxManualLines lines of each kind.
|
|
const maxGroundBody = 512 << 10
|
|
|
|
// dinnerCreate is the POST /dinners body: a name or a recipe_url.
|
|
type dinnerCreate struct {
|
|
Name *string `json:"name"`
|
|
RecipeURL *string `json:"recipe_url"`
|
|
}
|
|
|
|
// dinnerPatch is the PATCH /dinners/{id} body. Every field is optional;
|
|
// unknown fields and null values are refused.
|
|
type dinnerPatch struct {
|
|
Name *string `json:"name"`
|
|
Method *string `json:"method"`
|
|
MinutesTotal *int `json:"minutes_total"`
|
|
Difficulty *string `json:"difficulty"`
|
|
}
|
|
|
|
type dinnerList struct {
|
|
Dinners []dinners.Dinner `json:"dinners"`
|
|
}
|
|
|
|
// dinnerGround is the POST /dinners/{id}/ground body: exactly one of
|
|
// candidate_id, recipe_url and manual.
|
|
type dinnerGround struct {
|
|
CandidateID *string `json:"candidate_id"`
|
|
RecipeURL *string `json:"recipe_url"`
|
|
Manual *dinnerManualRecipe `json:"manual"`
|
|
}
|
|
|
|
type dinnerManualRecipe struct {
|
|
Ingredients []string `json:"ingredients"`
|
|
Steps []string `json:"steps"`
|
|
Minutes *int `json:"minutes"`
|
|
Method *string `json:"method"`
|
|
}
|
|
|
|
type candidateList struct {
|
|
Candidates []dinners.Candidate `json:"candidates"`
|
|
}
|
|
|
|
// getDinners answers GET /dinners?status=regular|quest_log.
|
|
func getDinners(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
list, err := svc.List(r.Context(), r.URL.Query().Get("status"))
|
|
if err != nil {
|
|
writeDinnerError(w, r, logger, "list dinners", err)
|
|
return
|
|
}
|
|
writeJSON(w, r, logger, http.StatusOK, dinnerList{Dinners: list})
|
|
}
|
|
}
|
|
|
|
// postDinner adds a Regular, recording the session's actor as updated_by,
|
|
// and answers 201 with it and its grounding_job_id without waiting for the
|
|
// job.
|
|
func postDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
actor, ok := actorOrFail(w, r, logger)
|
|
if !ok {
|
|
return
|
|
}
|
|
body, err := decodePatch[dinnerCreate](http.MaxBytesReader(w, r.Body, maxDinnerBody))
|
|
if err != nil {
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
|
return
|
|
}
|
|
d, err := svc.CreateRegular(r.Context(), dinners.NewRegular(body), actor.Subject)
|
|
if err != nil {
|
|
writeDinnerError(w, r, logger, "create dinner", err)
|
|
return
|
|
}
|
|
writeJSON(w, r, logger, http.StatusCreated, d)
|
|
}
|
|
}
|
|
|
|
// patchDinner edits a dinner, recording the session's actor as updated_by.
|
|
func patchDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
actor, ok := actorOrFail(w, r, logger)
|
|
if !ok {
|
|
return
|
|
}
|
|
patch, err := decodePatch[dinnerPatch](http.MaxBytesReader(w, r.Body, maxDinnerBody))
|
|
if err != nil {
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
|
return
|
|
}
|
|
d, err := svc.Update(r.Context(), chi.URLParam(r, "id"), dinners.Patch(patch), actor.Subject)
|
|
if err != nil {
|
|
writeDinnerError(w, r, logger, "update dinner", err)
|
|
return
|
|
}
|
|
writeJSON(w, r, logger, http.StatusOK, d)
|
|
}
|
|
}
|
|
|
|
// getDinnerCandidates answers GET /dinners/{id}/recipe-candidates with up
|
|
// to dinners.MaxCandidates recipe pages for the dinner's name.
|
|
func getDinnerCandidates(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
list, err := svc.Candidates(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
writeDinnerError(w, r, logger, "list recipe candidates", err)
|
|
return
|
|
}
|
|
writeJSON(w, r, logger, http.StatusOK, candidateList{Candidates: list})
|
|
}
|
|
}
|
|
|
|
// postDinnerGround grounds a dinner by hand in a candidate, a pasted link
|
|
// or a recipe typed in, recording the session's actor as updated_by.
|
|
func postDinnerGround(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
actor, ok := actorOrFail(w, r, logger)
|
|
if !ok {
|
|
return
|
|
}
|
|
body, err := decodePatch[dinnerGround](http.MaxBytesReader(w, r.Body, maxGroundBody))
|
|
if err != nil {
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
|
return
|
|
}
|
|
fix := dinners.Fix{CandidateID: body.CandidateID, RecipeURL: body.RecipeURL}
|
|
if body.Manual != nil {
|
|
m := dinners.ManualRecipe(*body.Manual)
|
|
fix.Manual = &m
|
|
}
|
|
d, err := svc.Ground(r.Context(), chi.URLParam(r, "id"), fix, actor.Subject)
|
|
if err != nil {
|
|
writeDinnerError(w, r, logger, "ground dinner", err)
|
|
return
|
|
}
|
|
writeJSON(w, r, logger, http.StatusOK, d)
|
|
}
|
|
}
|
|
|
|
// deleteDinner archives a dinner; the row is never deleted.
|
|
func deleteDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
actor, ok := actorOrFail(w, r, logger)
|
|
if !ok {
|
|
return
|
|
}
|
|
d, err := svc.Archive(r.Context(), chi.URLParam(r, "id"), actor.Subject)
|
|
if err != nil {
|
|
writeDinnerError(w, r, logger, "archive dinner", err)
|
|
return
|
|
}
|
|
writeJSON(w, r, logger, http.StatusOK, d)
|
|
}
|
|
}
|
|
|
|
// postDinnerFavorite toggles a dinner's is_favorite.
|
|
func postDinnerFavorite(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
actor, ok := actorOrFail(w, r, logger)
|
|
if !ok {
|
|
return
|
|
}
|
|
d, err := svc.ToggleFavorite(r.Context(), chi.URLParam(r, "id"), actor.Subject)
|
|
if err != nil {
|
|
writeDinnerError(w, r, logger, "toggle dinner favorite", err)
|
|
return
|
|
}
|
|
writeJSON(w, r, logger, http.StatusOK, d)
|
|
}
|
|
}
|
|
|
|
// actorOrFail returns the session's actor, or answers 500 when the request
|
|
// has none, which behind requireSession is a wiring bug.
|
|
func actorOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger) (auth.Actor, bool) {
|
|
actor, ok := auth.ActorFrom(r.Context())
|
|
if !ok {
|
|
logger.ErrorContext(r.Context(), r.Method+" "+r.URL.Path+" without a session")
|
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
|
}
|
|
return actor, ok
|
|
}
|
|
|
|
// writeDinnerError maps a dinners.Service error to its API error. A page
|
|
// that cannot ground the dinner is 422 ungroundable; recipe search not set
|
|
// up is 409 not_configured; a search or model failure is 502.
|
|
func writeDinnerError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
|
|
var invalid *dinners.ValidationError
|
|
var dup *dinners.DuplicateError
|
|
var ungroundable *recipes.ErrUngroundable
|
|
switch {
|
|
case errors.As(err, &ungroundable):
|
|
// The reasons quote dependency errors, so they are neither returned
|
|
// nor logged.
|
|
writeError(w, r, logger, http.StatusUnprocessableEntity, "ungroundable", "no usable recipe was found at that link")
|
|
case errors.Is(err, recipes.ErrSearchNotConfigured):
|
|
writeError(w, r, logger, http.StatusConflict, "not_configured", "recipe search is not configured")
|
|
case errors.Is(err, recipes.ErrSearchFailed):
|
|
logger.WarnContext(r.Context(), action, "error", err)
|
|
writeError(w, r, logger, http.StatusBadGateway, "search_failed", "recipe search failed")
|
|
case errors.Is(err, recipes.ErrNormalizeFailed):
|
|
logger.WarnContext(r.Context(), action, "reason", "normalize_recipe failed")
|
|
writeError(w, r, logger, http.StatusBadGateway, "ai_failed", "the AI provider failed to read the recipe")
|
|
case errors.As(err, &invalid):
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_dinner", invalid.Error())
|
|
case errors.As(err, &dup):
|
|
writeError(w, r, logger, http.StatusConflict, "duplicate_dinner", dup.Error())
|
|
case errors.Is(err, dinners.ErrNotFound):
|
|
writeError(w, r, logger, http.StatusNotFound, "not_found", "dinner not found")
|
|
default:
|
|
logger.ErrorContext(r.Context(), action, "error", err)
|
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
|
}
|
|
}
|