package httpapi import ( "context" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "os" "strconv" "strings" "time" "textmachine/platform/internal/auth" "textmachine/platform/internal/books" "textmachine/platform/internal/pgstore" "textmachine/platform/internal/pricing" "textmachine/platform/internal/runner" "textmachine/platform/internal/runs" ) // Library is the read side of the contract surface: everything the handlers below need, as an // interface, so this package keeps knowing nothing about SQL. type Library interface { ListBooks(ctx context.Context, userID string, limit int, cursor string) (pgstore.Library, error) GetBook(ctx context.Context, userID, bookID string) (pgstore.Book, *pgstore.Run, error) ReadUsage(ctx context.Context, userID string) (pgstore.Usage, error) } // Runs is the write side: the run lifecycle, as the HTTP layer needs to see it. type Runs interface { Bounds(ctx context.Context, userID, bookID string) (pricing.Bounds, error) Start(ctx context.Context, in runs.StartRequest) (pgstore.Run, error) Stop(ctx context.Context, userID, runID string) (pgstore.Run, error) Resume(ctx context.Context, userID, runID string) (pgstore.Run, error) } // Intake is the book upload, as the HTTP layer needs to see it. The service takes a READER: the // file is streamed to its place on disk and is never held in this process's memory. type Intake interface { Accept(ctx context.Context, in books.Intake) (pgstore.Book, error) } // contractRoutes registers the /v0 surface. // // Every path is written with the version prefix in the PATTERN and registered on the same mux as the // ops endpoints: a nested mux behind StripPrefix hands the inner handler a copy of the request and // the pattern never comes back, which would put raw paths carrying book ids into the access log. func contractRoutes(mux *http.ServeMux, d Deps, guard func(int64, http.Handler) http.Handler) { h := &v0{lib: d.Library, runs: d.Runs, intake: d.Intake, upload: d.Upload, log: d.Log} // The READS need a read model and nothing else. An instance with no engine binary is a read // replica, not a broken one, and mounting nothing unless it could ALSO start runs made it answer // 404 to a library it was holding — while its own boot line said it was serving one. if d.Library != nil { mux.Handle("GET "+APIPrefix+"/books", guard(DefaultMaxBody, http.HandlerFunc(h.listBooks))) mux.Handle("GET "+APIPrefix+"/books/{bookId}", guard(DefaultMaxBody, http.HandlerFunc(h.getBook))) mux.Handle("GET "+APIPrefix+"/usage", guard(DefaultMaxBody, http.HandlerFunc(h.usage))) } // The run surface needs the run lifecycle. Where it is absent the paths stay a guarded 404 rather // than a handler that answers 500 on every call. if d.Runs != nil { mux.Handle("GET "+APIPrefix+"/books/{bookId}/run-options", guard(DefaultMaxBody, http.HandlerFunc(h.runOptions))) mux.Handle("POST "+APIPrefix+"/books/{bookId}/runs", guard(DefaultMaxBody, http.HandlerFunc(h.startRun))) mux.Handle("POST "+APIPrefix+"/runs/{runId}/stop", guard(DefaultMaxBody, http.HandlerFunc(h.stopRun))) mux.Handle("POST "+APIPrefix+"/runs/{runId}/resume", guard(DefaultMaxBody, http.HandlerFunc(h.resumeRun))) } // Intake needs somewhere to put a file and an engine to cut it with; an instance with neither is // the read replica again, and its /books stays a guarded 404 for POST. // // This is the ONE route whose body limit is not the default, and it is why the limit is a // per-route argument in the first place (PD-35/PD-72): a book is tens of megabytes and every // other route on this surface carries a few kilobytes of JSON. if d.Intake != nil { mux.Handle("POST "+APIPrefix+"/books", guard(d.Upload.MaxBytes, http.HandlerFunc(h.createBook))) } } type v0 struct { lib Library runs Runs intake Intake upload UploadLimits log *slog.Logger } // The wire shapes below are the contract's, field for field (openapi 0.2.0). They are written out // rather than generated because a generated struct would still need the projection written by hand, // and a second copy of the field names is what makes a divergence invisible. type wireProgress struct { Draft wireCounter `json:"draft"` Edit wireCounter `json:"edit"` ETASeconds *int `json:"eta_seconds"` } type wireCounter struct { Done int `json:"done"` Total int `json:"total"` } type wireBook struct { ID string `json:"id"` Title string `json:"title"` SourceLang string `json:"source_lang"` TargetLang string `json:"target_lang"` Genre string `json:"genre"` ChapterCount int `json:"chapter_count"` CharacterCount int64 `json:"character_count"` AddedAt time.Time `json:"added_at"` Status string `json:"status"` Progress wireProgress `json:"progress"` NoteCount int `json:"note_count"` } type wireLibrary struct { Revision int64 `json:"revision"` NextCursor *string `json:"next_cursor"` Books []wireBook `json:"books"` } type wireRun struct { ID string `json:"id"` Revision int64 `json:"revision"` Status string `json:"status"` VerifyBank bool `json:"verify_bank"` CeilingChapters int `json:"ceiling_chapters"` PausedReason *string `json:"paused_reason"` StartedAt time.Time `json:"started_at"` FinishedAt *time.Time `json:"finished_at"` } type wireBookDetail struct { Revision int64 `json:"revision"` Book wireBook `json:"book"` Run *wireRun `json:"run"` } type wireCeilingBounds struct { MinChapters int `json:"min_chapters"` MaxChapters int `json:"max_chapters"` DefaultChapters int `json:"default_chapters"` } type wireRunOptions struct { Ceiling wireCeilingBounds `json:"ceiling"` } type wireRunRequest struct { // Pointers because both fields are REQUIRED and "absent" has to be told from "false" and from // "zero": a run started without a declared ceiling would spend past the limit the user is // entitled to set beforehand (contract §RunRequest). VerifyBank *bool `json:"verify_bank"` CeilingChapters *int `json:"ceiling_chapters"` } type wireUsage struct { State string `json:"state"` RemainingPercent int `json:"remaining_percent"` PausedReason *string `json:"paused_reason"` } func (h *v0) listBooks(w http.ResponseWriter, r *http.Request) { user, ok := principal(w, r) if !ok { return } limit, ok := pageLimit(w, r) if !ok { return } lib, err := h.lib.ListBooks(r.Context(), user, limit, r.URL.Query().Get("cursor")) if err != nil { h.fail(w, r, err) return } out := wireLibrary{Revision: lib.Revision, Books: make([]wireBook, 0, len(lib.Books))} if lib.NextCursor != "" { out.NextCursor = &lib.NextCursor } for _, b := range lib.Books { out.Books = append(out.Books, projectBook(b)) } writeJSON(w, r, http.StatusOK, out, h.log) } func (h *v0) getBook(w http.ResponseWriter, r *http.Request) { user, ok := principal(w, r) if !ok { return } book, run, err := h.lib.GetBook(r.Context(), user, r.PathValue("bookId")) if err != nil { h.fail(w, r, err) return } // ONE counter per book (contract §Revision: "every book-scoped read and the id of every stream // frame of that book's run carry the same number"). The store answers a run's revision from its // BOOK on every path that hands one out — the card here, and the stop and resume handles — so this // layer projects what it was given rather than carrying a second copy of the rule. out := wireBookDetail{Revision: book.Revision, Book: projectBook(book)} if run != nil { wr := projectRun(*run) out.Run = &wr } writeJSON(w, r, http.StatusOK, out, h.log) } func (h *v0) runOptions(w http.ResponseWriter, r *http.Request) { user, ok := principal(w, r) if !ok { return } b, err := h.runs.Bounds(r.Context(), user, r.PathValue("bookId")) if err != nil { h.fail(w, r, err) return } writeJSON(w, r, http.StatusOK, wireRunOptions{Ceiling: wireCeilingBounds{ MinChapters: b.Min, MaxChapters: b.Max, DefaultChapters: b.Default, }}, h.log) } func (h *v0) startRun(w http.ResponseWriter, r *http.Request) { user, ok := principal(w, r) if !ok { return } // Unknown properties are IGNORED, not refused: RunRequest does not declare // additionalProperties: false, and inside 0.x a minor bump is where optional fields appear — a // server that rejected the whole request would break a client generated against 0.3.0 for a field // it was free to ignore. var req wireRunRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { WriteProblem(w, http.StatusBadRequest, "Request could not be read", "") return } if req.VerifyBank == nil || req.CeilingChapters == nil { WriteProblem(w, http.StatusBadRequest, "Request is incomplete", "") return } // The schema's own minimum (RunRequest.ceiling_chapters, minimum: 1) belongs HERE and not in the // service: a request that violates the schema is malformed, and answering it with 409 would tell // the client the bounds had moved — so it would re-read run-options and retry, forever, a request // that can never succeed. if *req.CeilingChapters < 1 { WriteProblem(w, http.StatusBadRequest, "Request could not be read", "") return } run, err := h.runs.Start(r.Context(), runs.StartRequest{ UserID: user, BookID: r.PathValue("bookId"), VerifyBank: *req.VerifyBank, CeilingChapters: *req.CeilingChapters, }) if err != nil { h.fail(w, r, err) return } writeJSON(w, r, http.StatusAccepted, projectRun(run), h.log) } // stopRun is the product "stop" action (contract §stopRun). The engine stops gracefully on a signal // and the reconciler turns the exit into a status; what this call does is record that the stop was // OURS — which is the only way the exit can afterwards be told from a crash (register row PD-152). func (h *v0) stopRun(w http.ResponseWriter, r *http.Request) { user, ok := principal(w, r) if !ok { return } run, err := h.runs.Stop(r.Context(), user, r.PathValue("runId")) if err != nil { h.fail(w, r, err) return } writeJSON(w, r, http.StatusAccepted, projectRun(run), h.log) } // resumeRun continues a run that was stopped (contract §resumeRun). func (h *v0) resumeRun(w http.ResponseWriter, r *http.Request) { user, ok := principal(w, r) if !ok { return } run, err := h.runs.Resume(r.Context(), user, r.PathValue("runId")) if err != nil { h.fail(w, r, err) return } writeJSON(w, r, http.StatusAccepted, projectRun(run), h.log) } // maxIntakeField bounds one text field of the intake form, and maxIntakeParts how many parts may // arrive before the file. Neither is the contract's business: they are what keeps a form with a // megabyte-long genre, or a hundred thousand empty parts, from being work this process does. const ( maxIntakeField = 1 << 10 maxIntakeParts = 16 ) // createBook receives a book (contract §createBook). // // It is the only route on this surface that streams. The body is read part by part through // r.MultipartReader and the file is handed to the intake as a READER, so a 60 MB upload costs a // buffer and not 60 MB of this process — which is what ParseMultipartForm, the reflex alternative, // would have cost in memory or in a second copy through a temporary file. // // ⚠ The FILE PART MUST COME LAST. A streaming reader hands parts over in wire order, and the book's // row — which is what makes an upload visible while it arrives and findable when it dies halfway — // cannot be written before the languages that row requires. The same constraint is how S3's own // browser upload is specified ("the file or content must be the last field in the form"). It is a // wire rule the contract does not yet state; raised to the contract's owner rather than resolved // here (zone journal, P5). func (h *v0) createBook(w http.ResponseWriter, r *http.Request) { user, ok := principal(w, r) if !ok { return } // A book is minutes of upload on a domestic connection, and the server's ReadTimeout — the whole // of what bounds a half-fed request (PD-2) — covers the entire body. It is EXTENDED here for this // route and never cleared: net/http's own documentation points at a per-request deadline for // exactly this case, and clearing one instead is the mistake that re-created PD-2 (STACK §12). if err := http.NewResponseController(w).SetReadDeadline(time.Now().Add(h.upload.Deadline)); err != nil { // Not fatal: a ResponseWriter that cannot carry a deadline is a test double, and the real // server's own timeout then still applies. h.log.DebugContext(r.Context(), "upload deadline not extended", "err", err) } parts, err := r.MultipartReader() if err != nil { WriteProblem(w, http.StatusBadRequest, "Request could not be read", "") return } in := books.Intake{UserID: user} for n := 0; ; n++ { if n >= maxIntakeParts { WriteProblem(w, http.StatusBadRequest, "Request could not be read", "") return } part, err := parts.NextPart() if errors.Is(err, io.EOF) { // Every field and no file: the form the contract requires was not sent. WriteProblem(w, http.StatusBadRequest, "The upload is incomplete", "") return } if err != nil { h.uploadFailed(w, r, err) return } if part.FormName() == "file" { if in.SourceLang == "" || in.TargetLang == "" { // Either the languages were not sent, or they were sent AFTER the file — and the // second is indistinguishable from the first to a reader that streams. WriteProblem(w, http.StatusBadRequest, "The upload is incomplete", "") return } in.Filename, in.File = part.FileName(), part break } value, err := readField(part) if err != nil { h.uploadFailed(w, r, err) return } switch part.FormName() { case "source_lang": in.SourceLang = value case "target_lang": in.TargetLang = value case "genre": in.Genre = value } // An unknown field is IGNORED rather than refused, for the same reason an unknown JSON // property is: inside 0.x a minor bump is where optional fields appear, and a server that // rejected the whole upload would break a client generated against a later contract. } book, err := h.intake.Accept(r.Context(), in) if err != nil { h.uploadFailed(w, r, err) return } writeJSON(w, r, http.StatusCreated, projectBook(book), h.log) } // readField reads one text field of the form, refusing one that is too long rather than silently // keeping its first kilobyte. func readField(part io.Reader) (string, error) { b, err := io.ReadAll(io.LimitReader(part, maxIntakeField+1)) if err != nil { return "", err } if len(b) > maxIntakeField { return "", fmt.Errorf("%w: a form field is too long", books.ErrBadIntake) } return strings.TrimSpace(string(b)), nil } // uploadFailed maps the ways an upload ends badly. // // The size refusal is the reason PD-72 was opened and is closed with this route: MaxBytesReader is // what enforces the cap, and *http.MaxBytesError is how it says so — the same error whether the // client announced the size or simply kept sending. func (h *v0) uploadFailed(w http.ResponseWriter, r *http.Request, err error) { var tooLarge *http.MaxBytesError switch { case errors.As(err, &tooLarge): WriteProblem(w, http.StatusRequestEntityTooLarge, "The file is larger than this service accepts", "") case errors.Is(err, os.ErrDeadlineExceeded): // The body did not finish inside the route's own deadline. 408 is what RFC 9110 §15.5.9 calls // exactly this ("the server did not receive a complete request message within the time it was // prepared to wait"), and it tells a client that RETRYING is the remedy — which a 500 does not. // ⚠ Outside the codes the spec enumerates for this operation; named in the question package of // PD-180 rather than chosen silently. h.log.InfoContext(r.Context(), "upload did not finish inside the route's deadline") WriteProblem(w, http.StatusRequestTimeout, "The upload did not finish in time", "") case errors.Is(err, books.ErrBadIntake): WriteProblem(w, http.StatusBadRequest, "The upload is incomplete", "") case errors.Is(err, context.Canceled), errors.Is(err, io.ErrUnexpectedEOF), errors.Is(err, io.EOF): // The client went away mid-body. Nothing reaches it; the line is what an operator sees. h.log.InfoContext(r.Context(), "upload did not finish", "err", err) WriteProblem(w, http.StatusBadRequest, "The upload is incomplete", "") default: h.fail(w, r, err) } } func (h *v0) usage(w http.ResponseWriter, r *http.Request) { user, ok := principal(w, r) if !ok { return } u, err := h.lib.ReadUsage(r.Context(), user) if err != nil { h.fail(w, r, err) return } out := wireUsage{State: usageState(u.RemainingPercent, u.Spendable), RemainingPercent: u.RemainingPercent} // Through the same gate as the run's own reason, though this one can only be `credit_exhausted` // today (ReadUsage asks for exactly it): one place decides what the wire's vocabulary is. if reason := contractPausedReason(u.PausedReason); reason != "" { out.PausedReason = &reason } writeJSON(w, r, http.StatusOK, out, h.log) } // lowCredit is the share at which the interface warns. The threshold belongs to the platform and // never travels: a client that computed it from the percentage would carry a second copy of the // policy, and the two would diverge on the day it changes (contract §Usage). const lowCredit = 10 // spendable, not the percentage, decides "exhausted". The share is floored, so a large grant with a // small remainder rounds to 0% — and the account screen then said "nothing left" while the run dialog // offered a 300-chapter scale that started successfully. The two screens now answer from the same // fact. func usageState(percent int, spendable bool) string { switch { case !spendable: return "exhausted" case percent <= lowCredit: return "low" default: return "ok" } } func projectBook(b pgstore.Book) wireBook { return wireBook{ ID: b.ID, Title: b.Title, SourceLang: b.SourceLang, TargetLang: b.TargetLang, Genre: b.Genre, ChapterCount: b.ChapterCount, CharacterCount: b.CharacterCount, AddedAt: b.AddedAt, Status: b.Status, NoteCount: b.NoteCount, Progress: wireProgress{ Draft: wireCounter{Done: b.Progress.DraftDone, Total: b.Progress.DraftTotal}, Edit: wireCounter{Done: b.Progress.EditDone, Total: b.Progress.EditTotal}, ETASeconds: b.Progress.ETASeconds, }, } } func projectRun(r pgstore.Run) wireRun { out := wireRun{ ID: r.ID, Revision: r.Revision, Status: r.Status, VerifyBank: r.VerifyBank, CeilingChapters: r.CeilingChapters, StartedAt: r.StartedAt, FinishedAt: r.FinishedAt, } if reason := contractPausedReason(r.PausedReason); reason != "" { out.PausedReason = &reason } return out } // contractPausedReason is the wire vocabulary of PausedReason, which is NOT the platform's own. // // The contract enumerates one value (§PausedReason: `enum: [credit_exhausted]`), and the platform now // distinguishes a second internally — a halt on the ENGINE's own daily ceiling, which the platform // never set and which leaves the account with money on it (pgstore.CeilingPause). Inventing a wire // value for it is not this zone's right: the precedent is `books.reject_reason`, kept for an operator // and never projected (PD-173), and `last_resync_at` (PD-150). The spec is edited by ratification, // not by the zone that noticed. // // So a reason the contract has no word for travels as null, which is what the contract itself asks a // client to render — "show the neutral halted, resumable state rather than failing or guessing". The // question is with the contract's owner (register row PD-199); the day the spec names it, this // function is where the name arrives. func contractPausedReason(reason string) string { if reason == pgstore.PausedCreditExhausted { return reason } return "" } // principal reads the caller established by the middleware. Absent means the guard did not run, // which is a wiring defect rather than an unauthenticated request — so it is a 500, and it is // logged: answering 401 would hide a route mounted without its guard. func principal(w http.ResponseWriter, r *http.Request) (string, bool) { p, ok := auth.FromContext(r.Context()) if !ok || p.UserID == "" { WriteProblem(w, http.StatusInternalServerError, "Internal error", "") return "", false } return p.UserID, true } func pageLimit(w http.ResponseWriter, r *http.Request) (int, bool) { raw := r.URL.Query().Get("limit") if raw == "" { return 0, true // the collection's own default } n, err := strconv.Atoi(raw) if err != nil || n < 1 { WriteProblem(w, http.StatusBadRequest, "Request could not be read", "") return 0, false } return n, true } // fail maps a domain error onto the contract's status codes. // // Neither title nor detail ever carries engine or database text (contract §Problem): what a client // has to put on a screen is a product phrase, and an error string written for an operator becomes // the sentence a reader gets. func (h *v0) fail(w http.ResponseWriter, r *http.Request, err error) { switch { case errors.Is(err, pgstore.ErrNoBook), errors.Is(err, pgstore.ErrNoAccount), errors.Is(err, pgstore.ErrNoRun): WriteProblem(w, http.StatusNotFound, "Object not found", "") case errors.Is(err, pgstore.ErrBadCursor), errors.Is(err, books.ErrBadIntake): WriteProblem(w, http.StatusBadRequest, "Request could not be read", "") case errors.Is(err, pgstore.ErrRunInFlight): WriteProblem(w, http.StatusConflict, "This book is already being translated", "") case errors.Is(err, runs.ErrBookNotReady): // The book is still being received or was rejected. 409 and not 404: the book exists and the // client can see it — what it cannot do is start a translation of it yet. WriteProblem(w, http.StatusConflict, "This book is not ready to be translated", "") case errors.Is(err, runs.ErrNotStoppable): WriteProblem(w, http.StatusConflict, "This run is not running", "") case errors.Is(err, runs.ErrNotResumable): WriteProblem(w, http.StatusConflict, "This translation cannot be continued yet", "") case errors.Is(err, runs.ErrCeilingOutOfBounds), errors.Is(err, pgstore.ErrInsufficientCredit): // 409 and not 400: the request was legal when the bounds were read, and a hold taken for // another book between that read and this call is what moved them (contract §startRun). WriteProblem(w, http.StatusConflict, "The chosen limit no longer fits", "") case errors.Is(err, runner.ErrCeilingNotWired), errors.Is(err, runs.ErrRunnerIncomplete): // A DEPLOYMENT that cannot start runs: it has no way to tell the engine its ceiling (row 145) // or no way to record how a unit ended. ⚠ 503 is NOT among the statuses the contract // enumerates for this operation; it is used because every alternative lies — the request is // valid, the object exists, and the state is not in conflict. Raised as a question to the // contract's owner rather than resolved by editing the spec. h.log.ErrorContext(r.Context(), "run refused: this deployment cannot start runs", "err", err) WriteProblem(w, http.StatusServiceUnavailable, "Translation cannot be started right now", "") default: h.log.ErrorContext(r.Context(), "request failed", "err", err) WriteProblem(w, http.StatusInternalServerError, "Internal error", "") } } func writeJSON(w http.ResponseWriter, r *http.Request, status int, body any, log *slog.Logger) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(body); err != nil { log.DebugContext(r.Context(), "response body not delivered", "err", err) // the client went away } }