Build image / build-and-push (push) Successful in 6s
Gadfly findings on #94, the real ones: - scanSeedPacket extends only the READ deadline; a slow upload + a live vision call runs past the server's absolute 30s WriteTimeout and the successful response is silently dropped (the #78 failure mode). Extend the write deadline too (scanWriteTimeout). - An oversized upload tripping MaxBytesReader was mapped to 400; it's 413. Detect *http.MaxBytesError and report IMAGE_TOO_LARGE. - Split imagenorm error mapping: ErrTooLarge->413, ErrUnsupported->400, genuine read/encode faults (and a failed file.Open)->500, not 400. - CreateFromPacket discarded the plant it created when the lot then failed, contradicting its own doc. Roll the new plant back instead so the confirm is all-or-nothing (a fresh plant has no lots/plantings, so the delete is safe; log-and-continue on cleanup failure). - Dedup: packetLotRequest and seedLotCreateRequest shared every lot field. Extract a seedLotFields base both use. validCategory now reuses plantCategories. EffectiveConfig resolves agent+vision from one settings-row read instead of two. - capabilities swallowed an EffectiveVision error silently; log it. - vision test hand-copied Extract's body (drift risk). Split generate() out of Extract so the hermetic test drives the real request builder. Tests: rollback-on-lot-failure (service), oversized->413 (api). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
136 lines
5.5 KiB
Go
136 lines
5.5 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/imagenorm"
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
|
)
|
|
|
|
// Seed-packet capture (#81). Two steps, deliberately separate:
|
|
// POST /seed-lots/scan multipart image → a proposal (reads only)
|
|
// POST /seed-lots/from-packet confirmed proposal → a plant + lot
|
|
// The scan never writes; creation happens only from an explicit confirm, so a
|
|
// misread can't add anything to the catalog on its own.
|
|
|
|
// scanUploadLimit bounds the multipart body. imagenorm caps the decoded image at
|
|
// 25 MiB; this is a little over that for the multipart envelope. A phone photo is
|
|
// a few MB, so this is generous.
|
|
const scanUploadLimit = 30 << 20
|
|
|
|
// scanReadTimeout is how long we allow the image upload to take. The server's
|
|
// default ReadTimeout (15s) is fine for JSON but tight for a multi-megabyte photo
|
|
// on a slow phone connection, so this endpoint extends it — the same
|
|
// ResponseController mechanism the SSE path uses for writes (#78).
|
|
const scanReadTimeout = 60 * time.Second
|
|
|
|
// scanWriteTimeout extends the write deadline for the same reason. The server's
|
|
// absolute WriteTimeout (30s) is measured from the start of the request, but this
|
|
// handler's response can't be written until AFTER a slow upload AND a live vision
|
|
// call — together easily past 30s. Without this, a successful extraction's
|
|
// response is silently dropped: the exact failure mode #78 fixed for SSE.
|
|
const scanWriteTimeout = 120 * time.Second
|
|
|
|
// scanSeedPacket reads an uploaded packet photo and returns a proposal.
|
|
func (h *handlers) scanSeedPacket(c *gin.Context) {
|
|
// Extend both deadlines for the (potentially large, potentially slow) upload
|
|
// and the live vision call that follows. Best-effort: if the writer doesn't
|
|
// support it, the server defaults apply.
|
|
rc := http.NewResponseController(c.Writer)
|
|
_ = rc.SetReadDeadline(time.Now().Add(scanReadTimeout))
|
|
_ = rc.SetWriteDeadline(time.Now().Add(scanWriteTimeout))
|
|
|
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, scanUploadLimit)
|
|
file, err := c.FormFile("image")
|
|
if err != nil {
|
|
// A body over scanUploadLimit trips MaxBytesReader — that's 413, not a
|
|
// malformed request. Everything else here is a genuinely missing/garbled
|
|
// multipart field.
|
|
var tooBig *http.MaxBytesError
|
|
if errors.As(err, &tooBig) {
|
|
writeAPIError(c, http.StatusRequestEntityTooLarge, "IMAGE_TOO_LARGE", "that image is too large — try a smaller photo")
|
|
return
|
|
}
|
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "attach an image as the \"image\" field")
|
|
return
|
|
}
|
|
f, err := file.Open()
|
|
if err != nil {
|
|
// Opening the parsed upload failed on our side, not the client's.
|
|
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "could not read the uploaded image")
|
|
return
|
|
}
|
|
defer f.Close()
|
|
|
|
// Normalize to JPEG (decodes HEIC/webp/png/jpeg, downscales, re-encodes) so
|
|
// everything downstream — including the vision model — only sees a format it
|
|
// can read. This is where an iPhone HEIC becomes usable.
|
|
jpeg, _, err := imagenorm.Normalize(f, imagenorm.Options{})
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, imagenorm.ErrTooLarge):
|
|
writeAPIError(c, http.StatusRequestEntityTooLarge, "IMAGE_TOO_LARGE", "that image is too large — try a smaller photo")
|
|
case errors.Is(err, imagenorm.ErrUnsupported):
|
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "that doesn't look like an image we can read (JPEG, PNG, HEIC or WebP)")
|
|
default:
|
|
// A read or re-encode fault is ours, not bad input.
|
|
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "could not process the uploaded image")
|
|
}
|
|
return
|
|
}
|
|
|
|
prop, err := h.svc.ExtractSeedPacket(c.Request.Context(), mustActor(c).ID, jpeg)
|
|
if err != nil {
|
|
// A missing vision model surfaces as ErrInvalidInput from the service; give
|
|
// it a clearer message than the generic 400, since the UI shouldn't have
|
|
// offered the button at all in that case.
|
|
if errors.Is(err, domain.ErrInvalidInput) {
|
|
writeAPIError(c, http.StatusServiceUnavailable, "VISION_DISABLED", "packet scanning isn't set up on this instance")
|
|
return
|
|
}
|
|
writeServiceError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, prop)
|
|
}
|
|
|
|
// fromPacketRequest confirms a proposal: exactly one of plantId (attach to an
|
|
// existing plant) or newPlant (create a variety), plus the lot to record. The lot
|
|
// is seedLotFields — the create body's lot half WITHOUT plantId, since the plant
|
|
// comes from the plantId/newPlant choice, not the lot body.
|
|
type fromPacketRequest struct {
|
|
PlantID *int64 `json:"plantId"`
|
|
NewPlant *plantCreateRequest `json:"newPlant"`
|
|
Lot seedLotFields `json:"lot"`
|
|
}
|
|
|
|
// createFromPacket turns a confirmed proposal into a plant + lot.
|
|
func (h *handlers) createFromPacket(c *gin.Context) {
|
|
var req fromPacketRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a lot and exactly one of plantId or newPlant are required")
|
|
return
|
|
}
|
|
|
|
confirm := service.PacketConfirm{
|
|
PlantID: req.PlantID,
|
|
Lot: req.Lot.toInput(), // no plantId in the lot body; the service attributes it
|
|
}
|
|
if req.NewPlant != nil {
|
|
in := req.NewPlant.toInput()
|
|
confirm.NewPlant = &in
|
|
}
|
|
|
|
res, err := h.svc.CreateFromPacket(c.Request.Context(), mustActor(c).ID, confirm)
|
|
if err != nil {
|
|
writeServiceError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, res)
|
|
}
|