Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #90.
This commit is contained in:
@@ -9,11 +9,16 @@ import (
|
||||
|
||||
"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"`
|
||||
@@ -33,6 +38,25 @@ 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) {
|
||||
@@ -45,7 +69,10 @@ func getDinners(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// postDinner adds a Regular, recording the session's actor as updated_by.
|
||||
// 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. When the job cannot be enqueued it still answers 201, with the dinner
|
||||
// grounding_status failed and grounding_job_id null.
|
||||
func postDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := actorOrFail(w, r, logger)
|
||||
@@ -87,6 +114,46 @@ func patchDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -130,11 +197,32 @@ func actorOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger) (a
|
||||
return actor, ok
|
||||
}
|
||||
|
||||
// writeDinnerError maps a dinners.Service error to its API error.
|
||||
// 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; grounding by
|
||||
// hand that runs past dinners.GroundTimeout is 504 ground_timeout.
|
||||
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):
|
||||
// The error may quote the search provider's response, so only the
|
||||
// reason is logged.
|
||||
logger.WarnContext(r.Context(), action, "reason", "search_failed")
|
||||
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.Is(err, dinners.ErrGroundTimeout):
|
||||
logger.WarnContext(r.Context(), action, "reason", "ground_timeout")
|
||||
writeError(w, r, logger, http.StatusGatewayTimeout, "ground_timeout", "grounding the recipe took too long; try again")
|
||||
case errors.As(err, &invalid):
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_dinner", invalid.Error())
|
||||
case errors.As(err, &dup):
|
||||
|
||||
Reference in New Issue
Block a user