320 lines
15 KiB
Go
320 lines
15 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"textmachine/platform/internal/reqid"
|
|
)
|
|
|
|
// problem.go: the contract's error model (canon 0.3.0 §Problem, §ErrorCode; companion §2.17).
|
|
//
|
|
// What a client dispatches on is the machine `code`, never the words: `title` and `detail` are
|
|
// written for a developer and a log and a client MUST NOT show either. The phrase the user reads is
|
|
// drawn by the CLIENT from the code, in the language of its interface — which is why no wording
|
|
// here is a product decision and why none of it is translated.
|
|
//
|
|
// The vocabulary is TWO-LEVEL and that is the whole extensibility mechanism: the root `code` is
|
|
// closed for this version, `cause.code` is not. A new special case is added as a cause and breaks
|
|
// no client; inventing a root code is a contract change and belongs to a ratification, not to this
|
|
// package.
|
|
|
|
// Code is a root error code. Closed for contract version 0.3.0.
|
|
type Code string
|
|
|
|
const (
|
|
CodeInvalidRequest Code = "invalid_request"
|
|
CodeUnauthenticated Code = "unauthenticated"
|
|
CodeForbidden Code = "forbidden"
|
|
CodeNotFound Code = "not_found"
|
|
CodeGone Code = "gone"
|
|
CodeRequestTimeout Code = "request_timeout"
|
|
CodePayloadTooLarge Code = "payload_too_large"
|
|
CodeRunInFlight Code = "run_in_flight"
|
|
CodeBookNotReady Code = "book_not_ready"
|
|
CodeRunNotStoppable Code = "run_not_stoppable"
|
|
CodeRunNotResumable Code = "run_not_resumable"
|
|
CodeCeilingUnavailable Code = "ceiling_unavailable"
|
|
CodeIdempotencyConflict Code = "idempotency_conflict"
|
|
CodeContentRefused Code = "content_refused"
|
|
CodeServiceUnavailable Code = "service_unavailable"
|
|
CodeInternalError Code = "internal_error"
|
|
// The two bank codes of 0.5.0. They exist because their remedies have different ADDRESSEES: a
|
|
// refused set is re-decided by the person, an incomplete write is re-sent verbatim by the
|
|
// machine (canon §applyBankCorrections).
|
|
CodeBankCorrectionsRefused Code = "bank_corrections_refused"
|
|
CodeBankCorrectionsIncomplete Code = "bank_corrections_incomplete"
|
|
)
|
|
|
|
// Second-level causes this deployment gives. NOT a closed vocabulary — a client that does not know
|
|
// one falls back to the root code and loses only precision — so a value added here is not a
|
|
// contract change.
|
|
const (
|
|
// CauseCeilingReached — THIS RUN is finished with: it stopped at the limit it bought, or spent
|
|
// all of it. The remedy is a NEW run (canon §resumeRun).
|
|
CauseCeilingReached = "ceiling_reached"
|
|
// CauseCreditUnavailable — the run has room left and the ACCOUNT cannot cover the rest of it. The
|
|
// remedy is to top up, after which the same run continues; the two never stand in for one another.
|
|
CauseCreditUnavailable = "credit_unavailable"
|
|
// CauseBoundsMoved — the scale moved between the read and the call.
|
|
CauseBoundsMoved = "bounds_moved"
|
|
// CauseRePassUnavailable — a re-pass was asked for and there is nothing to re-pass: no
|
|
// correction was recorded since the last run, or a resnapshot run has already walked it in
|
|
// (canon §RunRequest.re_pass, 0.7.0).
|
|
CauseRePassUnavailable = "re_pass_unavailable"
|
|
// CauseNotPriced — this deployment has no price projection for the book yet, so it cannot quote an
|
|
// order over it. It is a state that CLEARS by itself: the projection lands with the book's reading
|
|
// surface, which the materializer owes and retries. Its own cause rather than `bounds_moved`,
|
|
// because the remedy is opposite — wait, do not re-read and retry in a loop.
|
|
CauseNotPriced = "not_priced"
|
|
// CauseChapterOrdersUnavailable — an order phrased in CHAPTERS over a book whose chapter cut is
|
|
// not trustworthy enough to sell against (see ingest.ChapterOrdersOffered). The remedy is in the
|
|
// options read: `order.chapter_orders` is false and `order.structure` says why.
|
|
CauseChapterOrdersUnavailable = "chapter_orders_unavailable"
|
|
// CauseCreditHeld — another book of this account holds the credit; `blocked` names it.
|
|
CauseCreditHeld = "credit_held"
|
|
// CauseKeyReused / CauseKeyInFlight — the two halves of `Idempotency-Key`.
|
|
CauseKeyReused = "key_reused"
|
|
CauseKeyInFlight = "key_in_flight"
|
|
// CauseCursorInvalid — a cursor from a structure that no longer exists (canon §NextCursor).
|
|
CauseCursorInvalid = "cursor_invalid"
|
|
// CauseVersionTooOld — a delta watermark that predates a wholesale replacement.
|
|
CauseVersionTooOld = "version_too_old"
|
|
// The two ways a request is refused before authorization. They are DIFFERENT facts and the
|
|
// canon gives them one root code (`forbidden`), so this is the only place they are told apart —
|
|
// which is what the second level is for.
|
|
CauseClientHeaderMissing = "client_header_missing"
|
|
CauseOriginRejected = "origin_rejected"
|
|
)
|
|
|
|
// Item codes of `errors[]` (canon §ErrorItem). Not closed either.
|
|
const (
|
|
ItemMissing = "missing"
|
|
ItemMissingOrLate = "missing_or_late"
|
|
ItemMalformed = "malformed"
|
|
ItemTooLong = "too_long"
|
|
ItemUnsupportedPair = "unsupported_pair"
|
|
ItemOutOfRange = "out_of_range"
|
|
// ItemUnknown has no producer in this build: the one branch that raised it went with the bank's
|
|
// per-term write path (D39.144). Kept because this block mirrors the canon's vocabulary rather
|
|
// than this build's use of it.
|
|
ItemUnknown = "unknown"
|
|
// The two answers the intake's own cut gives about the file. Told apart because the remedies are
|
|
// opposite; `errors[]` is an open vocabulary by the canon's own words.
|
|
//
|
|
// ItemNoBook — read and cut, and there is no book in it. The remedy is a different file.
|
|
ItemNoBook = "no_book"
|
|
// ItemNoChapterStructure — the book is there and nothing cut it into chapters, so a reader would
|
|
// get one document of any length. Temporary and about our reach (backlog rows 283/325).
|
|
ItemNoChapterStructure = "no_chapter_structure"
|
|
)
|
|
|
|
// codes is the closed root vocabulary of this contract version: every code, the status it names and
|
|
// its developer-facing title, in ONE table.
|
|
//
|
|
// One table because the two are a PAIR: the canon says each code names its status, and a code whose
|
|
// status and title were written in two switches is the class of divergence a reviewer cannot see —
|
|
// both halves look right on their own. The title is never shown to a user and never translated; the
|
|
// sentence a reader gets is the client's, drawn from the code.
|
|
var codes = map[Code]struct {
|
|
status int
|
|
title string
|
|
}{
|
|
CodeInvalidRequest: {http.StatusBadRequest, "Invalid request"},
|
|
CodeContentRefused: {http.StatusBadRequest, "Content refused"},
|
|
CodeUnauthenticated: {http.StatusUnauthorized, "Unauthenticated"},
|
|
CodeForbidden: {http.StatusForbidden, "Forbidden"},
|
|
CodeNotFound: {http.StatusNotFound, "Not found"},
|
|
CodeGone: {http.StatusGone, "Gone"},
|
|
CodeRequestTimeout: {http.StatusRequestTimeout, "Request timeout"},
|
|
CodePayloadTooLarge: {http.StatusRequestEntityTooLarge, "Payload too large"},
|
|
CodeRunInFlight: {http.StatusConflict, "Run in flight"},
|
|
CodeBookNotReady: {http.StatusConflict, "Book not ready"},
|
|
CodeRunNotStoppable: {http.StatusConflict, "Run not stoppable"},
|
|
CodeRunNotResumable: {http.StatusConflict, "Run not resumable"},
|
|
CodeCeilingUnavailable: {http.StatusConflict, "Ceiling unavailable"},
|
|
CodeIdempotencyConflict: {http.StatusConflict, "Idempotency conflict"},
|
|
CodeServiceUnavailable: {http.StatusServiceUnavailable, "Service unavailable"},
|
|
CodeInternalError: {http.StatusInternalServerError, "Internal error"},
|
|
|
|
CodeBankCorrectionsRefused: {http.StatusConflict, "Bank corrections refused"},
|
|
CodeBankCorrectionsIncomplete: {http.StatusServiceUnavailable, "Bank corrections incomplete"},
|
|
}
|
|
|
|
func statusOf(c Code) int {
|
|
if d, ok := codes[c]; ok {
|
|
return d.status
|
|
}
|
|
return http.StatusInternalServerError
|
|
}
|
|
|
|
func title(c Code) string {
|
|
if d, ok := codes[c]; ok {
|
|
return d.title
|
|
}
|
|
return "Internal error"
|
|
}
|
|
|
|
// Problem is an RFC 9457 error body with the contract's extension members.
|
|
//
|
|
// `type` stays `about:blank`: a URI that resolves nowhere is a promise we do not keep and a URN
|
|
// that repeats `code` is a second copy of one fact (RFC 9457 §3.1.1 wants `type` to be the
|
|
// identifier, §3.2 allows extension members to be it instead — companion §2.17).
|
|
//
|
|
// Detail NEVER carries engine or database text. The engine's own detail strings read like "CJK leak
|
|
// in the ru output: 第一节" — pipeline vocabulary that must not cross this seam (canon §Boundaries).
|
|
type Problem struct {
|
|
Type string `json:"type"`
|
|
Title string `json:"title"`
|
|
Status int `json:"status"`
|
|
Detail string `json:"detail,omitempty"`
|
|
Code Code `json:"code,omitempty"`
|
|
RequestID string `json:"request_id"`
|
|
// Cause is the narrower reason within Code, when there is one to give.
|
|
Cause *Cause `json:"cause,omitempty"`
|
|
// Errors names the parts of the request that were wrong. Carried by `invalid_request`; absent
|
|
// where there is no field to point at — a form with too many parts is reported by the root code
|
|
// alone (canon §ErrorItem).
|
|
Errors []Item `json:"errors,omitempty"`
|
|
// Blocked is carried by `ceiling_unavailable` when another book of the account holds the credit.
|
|
Blocked *Blocked `json:"blocked,omitempty"`
|
|
// Refusals is carried by `bank_corrections_refused`: one entry per refused correction, or one
|
|
// about the would-be result as a whole (canon §CorrectionRefusal).
|
|
Refusals []CorrectionRefusal `json:"refusals,omitempty"`
|
|
// RetryAfter is not a member: it is the header of the same name, and it is here so one writer
|
|
// sets it (canon §Conflict).
|
|
RetryAfter time.Duration `json:"-"`
|
|
}
|
|
|
|
// Cause is the second level of the code.
|
|
type Cause struct {
|
|
Code string `json:"code"`
|
|
}
|
|
|
|
// Item is one thing wrong with the request: a JSON Pointer at the member, or `/<name>` naming a
|
|
// form part, plus a machine code for it.
|
|
type Item struct {
|
|
Pointer string `json:"pointer"`
|
|
Code string `json:"code"`
|
|
}
|
|
|
|
// Blocked says what is holding the run scale down and which book is doing it. The same shape
|
|
// `RunOptions` answers with, because it is the same fact.
|
|
type Blocked struct {
|
|
Code string `json:"code"`
|
|
BookID string `json:"book_id"`
|
|
}
|
|
|
|
// CorrectionRefusal is one refused correction: a JSON Pointer at it (`/corrections/3`), or the
|
|
// EMPTY string for a refusal about the result as a whole. `detail` is developer-facing free text,
|
|
// like Problem's own — a client marks the row and draws a neutral phrase (canon §CorrectionRefusal).
|
|
type CorrectionRefusal struct {
|
|
Pointer string `json:"pointer"`
|
|
Detail string `json:"detail"`
|
|
}
|
|
|
|
// Fail writes an error identified by its root code.
|
|
func Fail(w http.ResponseWriter, r *http.Request, c Code) {
|
|
WriteProblem(w, r, Problem{Code: c})
|
|
}
|
|
|
|
// FailCause writes an error with its narrower cause.
|
|
func FailCause(w http.ResponseWriter, r *http.Request, c Code, cause string) {
|
|
WriteProblem(w, r, Problem{Code: c, Cause: &Cause{Code: cause}})
|
|
}
|
|
|
|
// Invalid writes a 400 naming the parts of the request that were wrong.
|
|
func Invalid(w http.ResponseWriter, r *http.Request, items ...Item) {
|
|
WriteProblem(w, r, Problem{Code: CodeInvalidRequest, Errors: items})
|
|
}
|
|
|
|
// WriteProblem renders an error response.
|
|
//
|
|
// The status comes from the code and the title from the code, so neither can disagree with it. A
|
|
// caller that has something to add fills Detail (for the log), Cause, Errors or Blocked.
|
|
func WriteProblem(w http.ResponseWriter, r *http.Request, p Problem) {
|
|
// The versioned surface answers a code on every response, `500` included. `Code` is omitempty
|
|
// only for the ungoverned surface below, and without this the omission leaked back here as a
|
|
// silent 500 with no code at all.
|
|
if p.Code == "" {
|
|
p.Code = CodeInternalError
|
|
}
|
|
p.Type = "about:blank"
|
|
p.Status = statusOf(p.Code)
|
|
p.Title = title(p.Code)
|
|
if r != nil {
|
|
p.RequestID = reqid.FromContext(r.Context())
|
|
}
|
|
if p.RequestID == "" {
|
|
// The middleware stamps every request, so an empty id means this response was produced
|
|
// outside it — a test double, or a handler mounted above reqid.Middleware. The field is
|
|
// required by the schema and a client MAY show it, so it gets a value rather than an empty
|
|
// string that reads as "this request had no id".
|
|
p.RequestID = reqid.New()
|
|
}
|
|
h := w.Header()
|
|
h.Set("Content-Type", "application/problem+json")
|
|
if p.Status == http.StatusUnauthorized {
|
|
// RFC 9110 §15.5.2 requires a challenge on every 401, and the canon declares the header
|
|
// REQUIRED on that response. Bearer is the scheme this API actually accepts; the session
|
|
// cookie is not an HTTP authentication scheme and cannot be named in a challenge. A client
|
|
// holding no session sends the user to the sign-in flow rather than parsing this.
|
|
h.Set("WWW-Authenticate", `Bearer realm="textmachine"`)
|
|
}
|
|
if p.RetryAfter > 0 {
|
|
h.Set("Retry-After", strconv.Itoa(int(p.RetryAfter.Round(time.Second).Seconds())))
|
|
}
|
|
w.WriteHeader(p.Status)
|
|
if err := json.NewEncoder(w).Encode(p); err != nil {
|
|
slog.Debug("problem body not delivered", "err", err) // the client went away mid-write
|
|
}
|
|
}
|
|
|
|
// WriteStatusProblem is the error writer for the surfaces the contract does NOT govern: the sign-in
|
|
// mechanics under /auth and the operational endpoints.
|
|
//
|
|
// ⚠ It emits NO `code`, and that is a decision rather than a gap. The sign-in surface has no machine
|
|
// vocabulary: a client shows ONE neutral phrase for every refusal there, with a single exception that
|
|
// needs no vocabulary at all — `429`, whose remedy travels in `Retry-After`, which is exactly what
|
|
// HTTP has that header for. Ratified in the companion's §2.14: «различать причины отказа клиент не
|
|
// может по замыслу».
|
|
//
|
|
// The transport defences that run BEFORE any handler — origin and marker-header checks — still
|
|
// answer the versioned `forbidden`: they guard every surface alike, and a defence that answered
|
|
// differently by path would be a second rule to keep in step.
|
|
//
|
|
// One writer per surface and no INVERSE function: the versioned surface derives its status from a
|
|
// code, this one takes the status it means at the call site. A status→code inverse would be a second
|
|
// source of truth — a code added to `statusOf` would never appear in it.
|
|
func WriteStatusProblem(w http.ResponseWriter, r *http.Request, status int, developerTitle, detail string) {
|
|
p := Problem{Type: "about:blank", Status: status, Title: developerTitle, Detail: detail}
|
|
if r != nil {
|
|
p.RequestID = reqid.FromContext(r.Context())
|
|
}
|
|
if p.RequestID == "" {
|
|
p.RequestID = reqid.New()
|
|
}
|
|
w.Header().Set("Content-Type", "application/problem+json")
|
|
w.WriteHeader(status)
|
|
if err := json.NewEncoder(w).Encode(p); err != nil {
|
|
slog.Debug("problem body not delivered", "err", err) // the client went away mid-write
|
|
}
|
|
}
|
|
|
|
// ProblemHandler is a static problem response, for the middleware that must be handed a denier.
|
|
func ProblemHandler(c Code) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
Fail(w, r, c)
|
|
})
|
|
}
|
|
|
|
// CauseHandler is the same for a denier that has a narrower cause to give — the two ways a request
|
|
// is refused before authorization (canon §Forbidden).
|
|
func CauseHandler(c Code, cause string) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
FailCause(w, r, c, cause)
|
|
})
|
|
}
|