582 lines
23 KiB
Go
582 lines
23 KiB
Go
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
|
|
}
|
|
}
|