277 lines
12 KiB
Go
277 lines
12 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"
|
|
)
|
|
|
|
// 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"
|
|
// 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 = "unknown"
|
|
)
|
|
|
|
// 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"},
|
|
}
|
|
|
|
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"`
|
|
// 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"`
|
|
}
|
|
|
|
// 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)
|
|
})
|
|
}
|