809 lines
35 KiB
Go
809 lines
35 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)
|
|
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 }},
|
|
// The correction door (canon 0.5.0). Unmounted it answers the guarded 404 the canon promises for
|
|
// `Capabilities.bank_corrections_enabled: false` — the same fact decides both. Its body cap IS
|
|
// the canon's 1 MiB document ceiling, so it rides the default deliberately.
|
|
{method: "POST", path: "/books/{bookId}/bank/corrections", mounts: hasBank, handler: func(h *v0) http.HandlerFunc { return h.bankCorrections }},
|
|
// 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 }
|
|
func hasBank(d Deps) bool { return d.Bank != 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, bank: d.Bank, 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
|
|
bank Bank
|
|
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.
|
|
//
|
|
// As of 0.6.0 no member here is ahead of the announced canon: `Run.stop_requested` landed in
|
|
// 0.4.0's Run (0.5.0 keeps it required), and the through-run bar with its `stage` member is what
|
|
// 0.6.0's Progress declares.
|
|
|
|
// `Progress` is the canon's through-run bar (0.6.0, D39.160): one monotonic fraction across both
|
|
// waves, and the `stage` caption derived by the PLATFORM from the same counters as the bar —
|
|
// never a forwarded engine string. `stage` is an OPEN vocabulary by the canon's own second
|
|
// exception: a client renders an unknown value neutrally, and new values do not move the version.
|
|
type wireProgress struct {
|
|
Done int `json:"done"`
|
|
Total int `json:"total"`
|
|
Stage string `json:"stage"`
|
|
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
|
|
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.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)
|
|
}
|
|
}
|