textmachine/platform/internal/httpapi/v0.go

364 lines
13 KiB
Go

package httpapi
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"time"
"textmachine/platform/internal/auth"
"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)
}
// 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, 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)))
}
}
type v0 struct {
lib Library
runs Runs
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, and it is the book's (contract §Revision: "every book-scoped read and the
// id of every stream frame of that book's run carry the same number"). Reading it off the run row
// made the card lag: a unit_done bumps the book and the chapter and not the run, so a client that
// had applied frame id=2 got revision 0 back and, obeying the contract, dropped the read.
out := wireBookDetail{Revision: book.Revision, Book: projectBook(book)}
if run != nil {
wr := projectRun(*run)
wr.Revision = book.Revision
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)
}
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}
if u.PausedReason != "" {
out.PausedReason = &u.PausedReason
}
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 r.PausedReason != "" {
out.PausedReason = &r.PausedReason
}
return out
}
// 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):
WriteProblem(w, http.StatusNotFound, "Object not found", "")
case errors.Is(err, pgstore.ErrBadCursor):
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.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
}
}