textmachine/platform/internal/httpapi/v0.go

804 lines
34 KiB
Go

package httpapi
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"mime/multipart"
"net/http"
"os"
"strconv"
"strings"
"time"
"textmachine/platform/internal/auth"
"textmachine/platform/internal/books"
"textmachine/platform/internal/ingest"
"textmachine/platform/internal/pgstore"
"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)
ListChapters(ctx context.Context, userID, bookID string, limit int, cursor string) (pgstore.ChapterPage, error)
ListUnits(ctx context.Context, userID, bookID, chapterID string, limit int, cursor string) (pgstore.UnitPage, error)
ListNotes(ctx context.Context, userID, bookID string, limit int, cursor string, after *int64) (pgstore.NotePage, error)
ListBank(ctx context.Context, userID, bookID string, limit int, cursor string, after *int64) (pgstore.BankPage, error)
SubmitBankDecisions(ctx context.Context, userID, bookID string, in []pgstore.BankDecision, now time.Time) (pgstore.BankReceipt, error)
ReadStream(ctx context.Context, userID, bookID string) (pgstore.StreamState, error)
ReadFrames(ctx context.Context, bookID string, after int64, limit int) ([]pgstore.Frame, 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) (runs.Options, 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)
}
// contractSurface is EVERY route of the /v0 surface, in one list.
//
// A list rather than a dozen mux.Handle calls so that "every contract route" is something code can
// enumerate: the session guard, the body limit and the tests all walk this, and a route added
// without them is not expressible.
var contractSurface = []struct {
method, path string
// mounts says which dependency this route needs. An instance without it leaves the path a guarded
// 404 rather than a handler that answers 500 on every call: an instance with no engine binary is a
// read replica, not a broken one.
mounts func(Deps) bool
handler func(*v0) http.HandlerFunc
// body overrides the default request limit. Only the upload has one (PD-35/PD-72): a book is tens
// of megabytes and every other route carries a few kilobytes of JSON.
body func(Deps) int64
}{
// `/capabilities` is answered by every instance, read model or not: it is how a client learns which
// contract version it is talking to, and an instance that hid it would be one a client cannot
// decide to refuse.
{method: "GET", path: "/capabilities", handler: func(h *v0) http.HandlerFunc { return h.capabilities }},
{method: "GET", path: "/books", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.listBooks }},
{method: "GET", path: "/books/{bookId}", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.getBook }},
{method: "GET", path: "/books/{bookId}/chapters", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.listChapters }},
{method: "GET", path: "/books/{bookId}/chapters/{chapterId}/units", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.listUnits }},
{method: "GET", path: "/books/{bookId}/notes", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.listNotes }},
{method: "GET", path: "/books/{bookId}/bank", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.listBank }},
{method: "POST", path: "/books/{bookId}/bank/decisions", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.bankDecisions }},
// The stream is registered outside the compression layer — compressing a stream buffers it, which
// is the one thing the contract forbids of this route — and that holds structurally: it does not
// go through writeJSON, which is where compression lives.
{method: "GET", path: "/books/{bookId}/events", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.streamEvents }},
{method: "GET", path: "/usage", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.usage }},
{method: "GET", path: "/books/{bookId}/run-options", mounts: hasRuns, handler: func(h *v0) http.HandlerFunc { return h.runOptions }},
{method: "POST", path: "/books/{bookId}/runs", mounts: hasRuns, handler: func(h *v0) http.HandlerFunc { return h.startRun }},
{method: "POST", path: "/runs/{runId}/stop", mounts: hasRuns, handler: func(h *v0) http.HandlerFunc { return h.stopRun }},
{method: "POST", path: "/runs/{runId}/resume", mounts: hasRuns, handler: func(h *v0) http.HandlerFunc { return h.resumeRun }},
{method: "POST", path: "/books", mounts: hasIntake, handler: func(h *v0) http.HandlerFunc { return h.createBook },
body: func(d Deps) int64 { return d.Upload.MaxBytes }},
}
func hasLibrary(d Deps) bool { return d.Library != nil }
func hasRuns(d Deps) bool { return d.Runs != nil }
func hasIntake(d Deps) bool { return d.Intake != nil }
// 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,
caps: d.Capabilities, keys: d.Keys, log: d.Log}
for _, r := range contractSurface {
if r.mounts != nil && !r.mounts(d) {
continue
}
limit := int64(DefaultMaxBody)
if r.body != nil {
limit = r.body(d)
}
mux.Handle(r.method+" "+APIPrefix+r.path, guard(limit, r.handler(h)))
}
}
type v0 struct {
lib Library
runs Runs
intake Intake
upload UploadLimits
caps Capabilities
keys IdempotencyKeys
log *slog.Logger
}
// The wire shapes below are the contract's, field for field. 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.
//
// Every one of them is an ALLOWLIST: a field not named here never reaches a client, and neither does
// a word from inside the translation machinery — which is why the projections below translate
// vocabularies instead of forwarding them.
//
// ⚠ `Run.stop_requested` is the ONE member ahead of the version this deployment announces: 0.3.0's
// Run does not declare it and 0.4.0 requires it. It is sent because the fact has no other carrier —
// no `status` answers "is this what I asked for" — and an unknown member is what a 0.x minor is for;
// the dependency is named here rather than left for a reader to find by diffing.
type wireProgress struct {
Done int `json:"done"`
Total int `json:"total"`
ETASeconds *int `json:"eta_seconds"`
}
type wireBook struct {
ID string `json:"id"`
Revision int64 `json:"revision"`
Title string `json:"title"`
SourceLang string `json:"source_lang"`
TargetLang string `json:"target_lang"`
Status string `json:"status"`
RejectReason *string `json:"reject_reason"`
StructureVersion int `json:"structure_version"`
ChapterCount int `json:"chapter_count"`
ChaptersDone int `json:"chapters_done"`
CharacterCount *int64 `json:"character_count"`
AddedAt time.Time `json:"added_at"`
NoteCount int `json:"note_count"`
}
type wireBookPage struct {
Revision int64 `json:"revision"`
NextCursor *string `json:"next_cursor"`
Books []wireBook `json:"books"`
}
type wireRun struct {
ID string `json:"id"`
BookID string `json:"book_id"`
Revision int64 `json:"revision"`
Status string `json:"status"`
StopForSigning bool `json:"stop_for_signing"`
StopRequested bool `json:"stop_requested"`
CeilingChapters int `json:"ceiling_chapters"`
Progress wireProgress `json:"progress"`
PausedReason *string `json:"paused_reason"`
FailureReason *string `json:"failure_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"`
Blocked *Blocked `json:"blocked"`
}
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 (canon §RunRequest).
StopForSigning *bool `json:"stop_for_signing"`
CeilingChapters *int `json:"ceiling_chapters"`
}
type wireUsage struct {
State string `json:"state"`
RemainingPercent int `json:"remaining_percent"`
HaltReason *string `json:"halt_reason"`
}
func (h *v0) listBooks(w http.ResponseWriter, r *http.Request) {
user, ok := principal(w, r)
if !ok {
return
}
lib, err := h.lib.ListBooks(r.Context(), user, h.pageLimit(r), r.URL.Query().Get("cursor"))
if err != nil {
h.fail(w, r, err)
return
}
out := wireBookPage{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))
}
h.writeJSON(w, r, http.StatusOK, out)
}
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 (canon §Revision: every book-scoped read and every frame of that book
// 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
}
h.writeJSON(w, r, http.StatusOK, out)
}
func (h *v0) runOptions(w http.ResponseWriter, r *http.Request) {
user, ok := principal(w, r)
if !ok {
return
}
opts, err := h.runs.Bounds(r.Context(), user, r.PathValue("bookId"))
if err != nil {
h.fail(w, r, err)
return
}
out := wireRunOptions{Ceiling: wireCeilingBounds{
MinChapters: opts.Ceiling.Min,
MaxChapters: opts.Ceiling.Max,
DefaultChapters: opts.Ceiling.Default,
}}
// Why the scale is smaller than the account could otherwise afford. Without it a second book
// shows a shrunken scale — or none — with no way to learn that the account's FIRST book is the
// reason (canon §RunOptions.blocked).
if opts.BlockedBy != "" {
out.Blocked = &Blocked{Code: CauseCreditHeld, BookID: opts.BlockedBy}
}
h.writeJSON(w, r, http.StatusOK, out)
}
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 a later contract
// for a field it was free to ignore.
// Read whole rather than streamed: the bytes are also this request's idempotency fingerprint.
//
// ⚠ A body that does not arrive — over the route's cap, or cut off — is `400`, like every other
// malformed JSON request and like the JSON write next door. It answered `413 payload_too_large`,
// a code the canon does not list among this operation's responses and defines as "over
// `intake_max_bytes`" — which is about an UPLOAD, and nothing here is one.
var req wireRunRequest
body, err := io.ReadAll(r.Body)
if err != nil {
h.log.InfoContext(r.Context(), "the body of a run request did not arrive", "err", err)
Invalid(w, r)
return
}
if err := json.Unmarshal(body, &req); err != nil {
Invalid(w, r)
return
}
var missing []Item
if req.StopForSigning == nil {
missing = append(missing, Item{Pointer: "/stop_for_signing", Code: ItemMissing})
}
if req.CeilingChapters == nil {
missing = append(missing, Item{Pointer: "/ceiling_chapters", Code: ItemMissing})
}
if len(missing) > 0 {
Invalid(w, r, missing...)
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 {
Invalid(w, r, Item{Pointer: "/ceiling_chapters", Code: ItemOutOfRange})
return
}
// Fingerprinted over the BODY: this request is small and entirely declared, so "the same
// request" is decidable exactly.
key, ok := h.beginIdempotent(w, r, user, body, fingerprintIsTheRequest)
if !ok {
return
}
defer key.release(r.Context()) // every exit settles the key; `complete` below cancels it
run, err := h.runs.Start(r.Context(), runs.StartRequest{
UserID: user,
BookID: r.PathValue("bookId"),
VerifyBank: *req.StopForSigning,
CeilingChapters: *req.CeilingChapters,
})
if err != nil {
h.fail(w, r, err)
return
}
key.complete(r.Context(), http.StatusAccepted, "", h.writeJSON(w, r, http.StatusAccepted, projectRun(run)), nil)
}
// stopRun is the product "stop" action (canon §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
}
h.writeJSON(w, r, http.StatusAccepted, projectRun(run))
}
// resumeRun continues a run that was stopped (canon §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
}
h.writeJSON(w, r, http.StatusAccepted, projectRun(run))
}
// 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 title, or a hundred thousand empty parts, from being work this process does.
const (
maxIntakeField = 1 << 10
maxIntakeParts = 16
)
// createBook receives a book (canon §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, and since 0.3.0 that is the contract's rule and not only this
// implementation's: 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. A part sent after the file is REFUSED and never
// ignored (canon §createBook), which is the half 0.2.3 got wrong: it was legal to lose one silently.
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 {
Invalid(w, r)
return
}
in := books.Intake{UserID: user}
titleGiven := false
// The file's own digest, taken as the bytes go past on their way to disk. It is what "the same
// request" means for this route, and it is why a repeat is read before it is answered.
digest := sha256.New()
// Inactive until claimed; deferred from here so every exit settles it. `complete` cancels it.
key := &idempotent{}
defer func() { key.release(r.Context()) }()
for n := 0; ; n++ {
if n >= maxIntakeParts {
// No field to point at: a form with too many parts is reported by the root code alone
// (canon §ErrorItem).
Invalid(w, r)
return
}
part, err := parts.NextPart()
if errors.Is(err, io.EOF) {
// Every field and no file: the form the contract requires was not sent.
Invalid(w, r, Item{Pointer: "/file", Code: ItemMissing})
return
}
if err != nil {
h.uploadFailed(w, r, err)
return
}
if part.FormName() == "file" {
if bad := missingIntakeFields(in, titleGiven); len(bad) > 0 {
// Either those fields were not sent, or they were sent AFTER the file — and the second
// is indistinguishable from the first to a reader that streams, which is exactly what
// the contract's `missing_or_late` says.
Invalid(w, r, bad...)
return
}
in.Filename = part.FileName()
// The FILE is digested as it goes past, and that digest is what settles "the same request"
// for this route: the form declares its parts and not its bytes, so two different books
// arrive under one key with the same declaration.
in.File = io.TeeReader(part, digest)
// The claim is taken HERE — after the declared parts are known and before a single byte of
// the file is written. Later would create the duplicate the key exists to prevent; earlier
// would have nothing to fingerprint.
claimed, ok := h.beginIdempotent(w, r, user, intakeFingerprint(in, titleGiven), bodyDecidesIdentity)
if !ok {
return
}
key = claimed
if key.replay != nil {
// A repeat of a completed attempt. Its bytes are read and thrown away — nothing is
// written and no book is created — and only then is it decided whether this is the
// same request or another one wearing its key.
if _, err := io.Copy(io.Discard, in.File); err != nil {
// The body did not arrive, so identity cannot be established. Whatever else is true,
// the stored answer is not owed to a request nobody could read.
h.uploadFailed(w, r, err)
return
}
// A part after the file is refused HERE too. It is refused for a fresh key, and a repeat
// that carried one and was answered 201 would make the same body legal or not depending
// on which key it wore.
switch next, err := parts.NextPart(); {
case err == nil:
Invalid(w, r, Item{Pointer: "/" + next.FormName(), Code: ItemMissingOrLate})
return
case !errors.Is(err, io.EOF):
h.uploadFailed(w, r, err)
return
}
key.replayIfIdentical(w, r, digest.Sum(nil))
return
}
// The file is the LAST part: everything after it is refused rather than ignored, and the
// refusal has to happen before the bytes are consumed — so the remainder is checked by
// handing the intake a reader that fails at the end of the body. See trailingParts.
break
}
value, err := readField(part)
if err != nil {
// A field longer than this deployment reads is named rather than folded into "the request
// could not be read": the canon asks the 400 to say which part was wrong.
if errors.Is(err, books.ErrBadIntake) {
Invalid(w, r, Item{Pointer: "/" + part.FormName(), Code: ItemTooLong})
return
}
h.uploadFailed(w, r, err)
return
}
switch part.FormName() {
case "title":
// The EMPTY STRING is a value and not an absence: it means "name it from the file"
// (canon §BookIntake.title). Anything else is a name the person chose, and no later
// parse overwrites it.
in.Title, titleGiven = value, true
case "source_lang":
in.SourceLang = value
case "target_lang":
in.TargetLang = 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.
}
// A part after the file is REFUSED and never ignored, and the refusal has to undo the upload:
// answering 400 while the book stays in the library would leave the user with an error and a
// book. It is done by failing the READ — the intake's own "the upload did not finish" path then
// removes the row and the directory, which is the path that already exists for exactly this.
trailing := &trailingParts{parts: parts}
in.File = io.MultiReader(in.File, trailing)
// A `408` is not a completed attempt, so the same key may be presented again (canon §createBook):
// that is what the deferred release does for every failure below.
book, err := h.intake.Accept(r.Context(), in)
if err != nil {
if errors.Is(err, errTrailingPart) {
h.log.InfoContext(r.Context(), "an upload carried a part after the file and was refused")
Invalid(w, r, Item{Pointer: "/" + trailing.name, Code: ItemMissingOrLate})
return
}
h.uploadFailed(w, r, err)
return
}
// Location names the book card, as a URI reference resolved against this request's URL: a client
// follows it as given rather than rebuilding the address from an identifier of its own.
location := APIPrefix + "/books/" + book.ID
w.Header().Set("Location", location)
key.complete(r.Context(), http.StatusCreated, location,
h.writeJSON(w, r, http.StatusCreated, projectBook(book)), digest.Sum(nil))
}
// intakeFingerprint is what a claim can be taken on: the DECLARED parts, known before a byte of the
// file has been read.
//
// It does not settle identity by itself and is not asked to. The file's own digest does, on the way
// past, and a repeat is compared against it before anything is replayed (idempotent.replayIfIdentical).
//
// ⚠ The request's `Content-Length` is NOT in here. It stood in for the file's size and was a proxy
// for neither half of the question: it counts the multipart framing, so a retry from another client
// library differs, and a chunked body declares nothing at all — which left two different files under
// one key indistinguishable (PD-262).
func intakeFingerprint(in books.Intake, titleGiven bool) []byte {
return []byte(strconv.FormatBool(titleGiven) + "\x00" + in.Title + "\x00" +
in.SourceLang + "\x00" + in.TargetLang + "\x00" + in.Filename)
}
// missingIntakeFields names what the form did not carry before its file part.
func missingIntakeFields(in books.Intake, titleGiven bool) []Item {
var out []Item
if !titleGiven {
out = append(out, Item{Pointer: "/title", Code: ItemMissingOrLate})
}
if in.SourceLang == "" {
out = append(out, Item{Pointer: "/source_lang", Code: ItemMissingOrLate})
}
if in.TargetLang == "" {
out = append(out, Item{Pointer: "/target_lang", Code: ItemMissingOrLate})
}
return out
}
// errTrailingPart is a form that carried something after its file.
var errTrailingPart = errors.New("httpapi: a form part arrived after the file")
// trailingParts is what turns "the file is the last part" from this implementation's constraint into
// the contract's refusal.
//
// It reads as an empty tail of the file and, when the file's own bytes are exhausted, asks the
// multipart reader whether anything follows. A part that does FAILS the read, which is what makes
// the refusal undo the upload: the intake treats a read that failed as an upload that did not
// finish and removes the row and the directory it had already written. The part's NAME is kept
// beside the error because the caller reports it as the offending field.
type trailingParts struct {
parts *multipart.Reader
checked bool
name string
}
func (t *trailingParts) Read([]byte) (int, error) {
if t.checked {
return 0, io.EOF
}
t.checked = true
part, err := t.parts.NextPart()
if err != nil {
return 0, io.EOF // the form ended where it should: after the file
}
t.name = part.FormName()
if t.name == "" {
t.name = "form"
}
return 0, errTrailingPart
}
// 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):
Fail(w, r, CodePayloadTooLarge)
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, and it tells a client that RETRYING is the remedy — which a 500 does not.
h.log.InfoContext(r.Context(), "upload did not finish inside the route's deadline")
Fail(w, r, CodeRequestTimeout)
case errors.Is(err, books.ErrMalformedLanguage):
// WHICH code is malformed is in the error's own tail; both are named when only one is, because
// a pair is what was rejected and pointing at one of the two would be a guess about which the
// person meant to change.
Invalid(w, r,
Item{Pointer: "/source_lang", Code: ItemMalformed},
Item{Pointer: "/target_lang", Code: ItemMalformed})
case errors.Is(err, books.ErrUnsupportedPair):
Invalid(w, r,
Item{Pointer: "/source_lang", Code: ItemUnsupportedPair},
Item{Pointer: "/target_lang", Code: ItemUnsupportedPair})
case errors.Is(err, books.ErrBadIntake):
Invalid(w, r)
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)
Invalid(w, r)
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}
// ⚠ Its own vocabulary, AccountHaltReason, and not the run's. A run stops for reasons that say
// nothing about the account, and lighting an account-wide state from one would tell a user with
// money that they have none (canon §AccountHaltReason). The two happen to share a value today and
// the types are separate on purpose.
if reason := ingest.ContractHaltReason(u.PausedReason); reason != "" {
out.HaltReason = &reason
}
h.writeJSON(w, r, http.StatusOK, out)
}
// 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 (canon §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"
}
}
// 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 == "" {
Fail(w, r, CodeInternalError)
return "", false
}
return p.UserID, true
}
// pageLimit reads the `limit` parameter.
//
// It NEVER refuses. Over the maximum the page size is clamped and answered, and anything else — a
// zero, a negative, a word — falls back to the deployment's default: the canon says outright that "a
// deployment that validates this parameter against the schema has to exempt it from rejection"
// (§Limit), because answering the default instead of clamping is what made "ask for more, get fewer
// rows than a smaller request" discoverable only by experiment. The clamp itself lives in the store,
// next to the page it bounds.
func (h *v0) pageLimit(r *http.Request) int {
raw := r.URL.Query().Get("limit")
if raw == "" {
return 0 // the deployment's default (GET /capabilities)
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 {
return 0
}
return n
}
// afterVersion reads the delta parameter shared by the notes and the bank.
func (h *v0) afterVersion(w http.ResponseWriter, r *http.Request) (*int64, bool) {
raw := r.URL.Query().Get("after_version")
if raw == "" {
return nil, true
}
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil || n < 0 {
Invalid(w, r, Item{Pointer: "/after_version", Code: ItemMalformed})
return nil, false
}
return &n, true
}
// fail maps a domain error onto the contract's codes.
//
// Neither title nor detail ever carries engine or database text (canon §Problem): what a client puts
// on a screen is drawn from the CODE, and an error string written for an operator would otherwise
// become the sentence a reader gets.
func (h *v0) fail(w http.ResponseWriter, r *http.Request, err error) {
var held *runs.CreditHeldError
var unknown *pgstore.UnknownTermError
switch {
case errors.Is(err, pgstore.ErrNoBook), errors.Is(err, pgstore.ErrNoAccount), errors.Is(err, pgstore.ErrNoRun):
Fail(w, r, CodeNotFound)
case errors.Is(err, pgstore.ErrNoChapter):
// The chapter existed and does not any more: a book cut again leaves the old ids GONE rather
// than absent, and the remedy differs — the client re-reads the tree instead of checking the
// address (canon §listUnits).
Fail(w, r, CodeGone)
case errors.Is(err, pgstore.ErrBadCursor):
FailCause(w, r, CodeInvalidRequest, CauseCursorInvalid)
case errors.Is(err, pgstore.ErrVersionTooOld):
FailCause(w, r, CodeInvalidRequest, CauseVersionTooOld)
case errors.As(err, &unknown):
Invalid(w, r, Item{Pointer: decisionPointer(unknown.Item, "term_id"), Code: ItemUnknown})
case errors.Is(err, books.ErrBadIntake):
Invalid(w, r)
case errors.Is(err, pgstore.ErrRunInFlight):
Fail(w, r, CodeRunInFlight)
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.
Fail(w, r, CodeBookNotReady)
case errors.Is(err, runs.ErrNotStoppable):
Fail(w, r, CodeRunNotStoppable)
case errors.Is(err, runs.ErrCeilingReached):
// A run stopped at a limit is not continued by `resume`: the limit travels with the START of
// a run, so the remedy is a NEW run with a larger one (canon §resumeRun).
FailCause(w, r, CodeRunNotResumable, CauseCeilingReached)
case errors.Is(err, runs.ErrCreditUnavailable):
// The run itself has room left; the account does not. The remedy is money, not a new run.
FailCause(w, r, CodeRunNotResumable, CauseCreditUnavailable)
case errors.Is(err, runs.ErrNotResumable):
Fail(w, r, CodeRunNotResumable)
case errors.As(err, &held):
WriteProblem(w, r, Problem{Code: CodeCeilingUnavailable,
Cause: &Cause{Code: CauseCreditHeld},
Blocked: &Blocked{Code: CauseCreditHeld, BookID: held.BookID}})
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 (canon §startRun).
FailCause(w, r, CodeCeilingUnavailable, CauseBoundsMoved)
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.
h.log.ErrorContext(r.Context(), "run refused: this deployment cannot start runs", "err", err)
Fail(w, r, CodeServiceUnavailable)
default:
h.log.ErrorContext(r.Context(), "request failed", "err", err)
Fail(w, r, CodeInternalError)
}
}