300 lines
9.6 KiB
Go
300 lines
9.6 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"textmachine/platform/internal/ingest"
|
|
"textmachine/platform/internal/pgstore"
|
|
)
|
|
|
|
// reading.go: the chapter tree, its pairs, the book's notes and its memory bank.
|
|
//
|
|
// One rule shapes all four: a collection answers ONE page and the revision that page is a picture
|
|
// of, read in the same transaction. The client stamps a multi-page WALK with the lowest revision it
|
|
// saw and uses that as the watermark of its next delta read (canon §Revision) — server-side that
|
|
// means every page carries its own number and none of them carries a promise about the others.
|
|
|
|
type wireChapter struct {
|
|
ID string `json:"id"`
|
|
Number *int `json:"number"`
|
|
// Heading is the chapter's label as it comes from the DATA of the book, or null. A deployment
|
|
// whose parser does not extract labels answers null and MUST NOT put a rendered ordinal here.
|
|
Heading *string `json:"heading"`
|
|
UnitsTotal int `json:"units_total"`
|
|
UnitsDone int `json:"units_done"`
|
|
NoteCount int `json:"note_count"`
|
|
}
|
|
|
|
type wireChapterPage struct {
|
|
Revision int64 `json:"revision"`
|
|
NextCursor *string `json:"next_cursor"`
|
|
StructureVersion int `json:"structure_version"`
|
|
Chapters []wireChapter `json:"chapters"`
|
|
}
|
|
|
|
type wireUnit struct {
|
|
ID string `json:"id"`
|
|
Source string `json:"source"`
|
|
// Target is non-empty exactly when State is `translated`, the empty string otherwise — never
|
|
// absent, never null.
|
|
Target string `json:"target"`
|
|
State string `json:"state"`
|
|
Notes []wireNote `json:"notes"`
|
|
}
|
|
|
|
type wireUnitPage struct {
|
|
Revision int64 `json:"revision"`
|
|
NextCursor *string `json:"next_cursor"`
|
|
StructureVersion int `json:"structure_version"`
|
|
Units []wireUnit `json:"units"`
|
|
}
|
|
|
|
type wireNote struct {
|
|
ID string `json:"id"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
Severity string `json:"severity"`
|
|
Code string `json:"code"`
|
|
ChapterID string `json:"chapter_id"`
|
|
// UnitID is optional and for one reason only: a note is about a pair, or about a whole chapter.
|
|
UnitID *string `json:"unit_id,omitempty"`
|
|
}
|
|
|
|
type wireNotePage struct {
|
|
Revision int64 `json:"revision"`
|
|
NextCursor *string `json:"next_cursor"`
|
|
StructureVersion int `json:"structure_version"`
|
|
Notes []wireNote `json:"notes"`
|
|
}
|
|
|
|
type wireTerm struct {
|
|
ID string `json:"id"`
|
|
Src string `json:"src"`
|
|
Dst string `json:"dst"`
|
|
Kind *string `json:"kind"`
|
|
Status string `json:"status"`
|
|
Origin string `json:"origin"`
|
|
Sense string `json:"sense"`
|
|
// The window is in chapter NUMBERS, which are not keys: it lives in the coordinates of the
|
|
// current structure_version. null means "no boundary".
|
|
SinceChapter *int `json:"since_chapter"`
|
|
UntilChapter *int `json:"until_chapter"`
|
|
}
|
|
|
|
// wireBankPage carries the whole-bank aggregates on the FIRST page only — any response to a request
|
|
// with no cursor, a delta read included. They are `omitempty` for that reason and for no other:
|
|
// absence here means "does not apply to this page", one of the two places on this surface where it
|
|
// does (canon §BankPage).
|
|
type wireBankPage struct {
|
|
Revision int64 `json:"revision"`
|
|
NextCursor *string `json:"next_cursor"`
|
|
StructureVersion int `json:"structure_version"`
|
|
Total *int `json:"total,omitempty"`
|
|
Signed *int `json:"signed,omitempty"`
|
|
PendingDecisions *int `json:"pending_decisions,omitempty"`
|
|
Complete *bool `json:"complete,omitempty"`
|
|
Terms []wireTerm `json:"terms"`
|
|
}
|
|
|
|
type wireBankDecision struct {
|
|
TermID string `json:"term_id"`
|
|
Action string `json:"action"`
|
|
Dst string `json:"dst"`
|
|
}
|
|
|
|
type wireBankDecisions struct {
|
|
Decisions []wireBankDecision `json:"decisions"`
|
|
}
|
|
|
|
type wireBankReceipt struct {
|
|
Revision int64 `json:"revision"`
|
|
PendingDecisions int `json:"pending_decisions"`
|
|
Complete bool `json:"complete"`
|
|
}
|
|
|
|
func (h *v0) listChapters(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := principal(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
page, err := h.lib.ListChapters(r.Context(), user, r.PathValue("bookId"), h.pageLimit(r), r.URL.Query().Get("cursor"))
|
|
if err != nil {
|
|
h.fail(w, r, err)
|
|
return
|
|
}
|
|
out := wireChapterPage{
|
|
Revision: page.Revision, StructureVersion: page.StructureVersion,
|
|
Chapters: make([]wireChapter, 0, len(page.Chapters)),
|
|
}
|
|
if page.NextCursor != "" {
|
|
out.NextCursor = &page.NextCursor
|
|
}
|
|
for _, c := range page.Chapters {
|
|
out.Chapters = append(out.Chapters, projectChapter(c))
|
|
}
|
|
h.writeJSON(w, r, http.StatusOK, out)
|
|
}
|
|
|
|
// listUnits answers the pairs of ONE chapter.
|
|
//
|
|
// Per chapter and only per chapter: a book-wide pairs endpoint is never introduced, and the client's
|
|
// whole memory model stands on that (canon §listUnits). A chapter that existed and no longer does is
|
|
// `410`, so a client re-reads the tree rather than checking the address.
|
|
func (h *v0) listUnits(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := principal(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
page, err := h.lib.ListUnits(r.Context(), user, r.PathValue("bookId"), r.PathValue("chapterId"),
|
|
h.pageLimit(r), r.URL.Query().Get("cursor"))
|
|
if err != nil {
|
|
h.fail(w, r, err)
|
|
return
|
|
}
|
|
out := wireUnitPage{
|
|
Revision: page.Revision, StructureVersion: page.StructureVersion,
|
|
Units: make([]wireUnit, 0, len(page.Units)),
|
|
}
|
|
if page.NextCursor != "" {
|
|
out.NextCursor = &page.NextCursor
|
|
}
|
|
for _, u := range page.Units {
|
|
out.Units = append(out.Units, projectUnit(u))
|
|
}
|
|
h.writeJSON(w, r, http.StatusOK, out)
|
|
}
|
|
|
|
func (h *v0) listNotes(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := principal(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
after, ok := h.afterVersion(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
page, err := h.lib.ListNotes(r.Context(), user, r.PathValue("bookId"), h.pageLimit(r),
|
|
r.URL.Query().Get("cursor"), after)
|
|
if err != nil {
|
|
h.fail(w, r, err)
|
|
return
|
|
}
|
|
out := wireNotePage{
|
|
Revision: page.Revision, StructureVersion: page.StructureVersion,
|
|
Notes: make([]wireNote, 0, len(page.Notes)),
|
|
}
|
|
if page.NextCursor != "" {
|
|
out.NextCursor = &page.NextCursor
|
|
}
|
|
unnamed := 0
|
|
for _, n := range page.Notes {
|
|
note := projectNote(n)
|
|
if note.Code == ingest.NoteCodeUnspecified {
|
|
unnamed++
|
|
}
|
|
out.Notes = append(out.Notes, note)
|
|
}
|
|
if unnamed > 0 {
|
|
// A flag reason the contract's map does not name. Loud, because the answer is a line in that
|
|
// map rather than a change here — and silent, it would look like a note the engine produced
|
|
// without a reason.
|
|
h.log.ErrorContext(r.Context(), "notes carry a flag reason this build cannot name; the contract's map needs a line",
|
|
"notes", unnamed)
|
|
}
|
|
h.writeJSON(w, r, http.StatusOK, out)
|
|
}
|
|
|
|
// listBank answers the memory bank, and it is also the STATE of a signing stop — informationally,
|
|
// never as a gate. Signing is ONE act over the whole bank: `resume` lifts the stop with the
|
|
// decisions as they stand, and no counter here decides whether continuing may be offered (D39.144).
|
|
func (h *v0) listBank(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := principal(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
after, ok := h.afterVersion(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
page, err := h.lib.ListBank(r.Context(), user, r.PathValue("bookId"), h.pageLimit(r),
|
|
r.URL.Query().Get("cursor"), after)
|
|
if err != nil {
|
|
h.fail(w, r, err)
|
|
return
|
|
}
|
|
out := wireBankPage{
|
|
Revision: page.Revision, StructureVersion: page.StructureVersion,
|
|
Terms: make([]wireTerm, 0, len(page.Terms)),
|
|
}
|
|
if page.NextCursor != "" {
|
|
out.NextCursor = &page.NextCursor
|
|
}
|
|
if page.First {
|
|
c := page.Counts
|
|
out.Total, out.Signed = &c.Total, &c.Signed
|
|
out.PendingDecisions, out.Complete = &c.PendingDecisions, &c.Complete
|
|
}
|
|
for _, t := range page.Terms {
|
|
out.Terms = append(out.Terms, projectTerm(t))
|
|
}
|
|
h.writeJSON(w, r, http.StatusOK, out)
|
|
}
|
|
|
|
// maxDecisions bounds one submission, as the contract's schema does.
|
|
const maxDecisions = 1000
|
|
|
|
func (h *v0) bankDecisions(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := principal(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req wireBankDecisions
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
Invalid(w, r)
|
|
return
|
|
}
|
|
if len(req.Decisions) == 0 {
|
|
Invalid(w, r, Item{Pointer: "/decisions", Code: ItemMissing})
|
|
return
|
|
}
|
|
if len(req.Decisions) > maxDecisions {
|
|
Invalid(w, r, Item{Pointer: "/decisions", Code: ItemOutOfRange})
|
|
return
|
|
}
|
|
in := make([]pgstore.BankDecision, 0, len(req.Decisions))
|
|
var bad []Item
|
|
for i, d := range req.Decisions {
|
|
switch {
|
|
case d.TermID == "":
|
|
bad = append(bad, Item{Pointer: decisionPointer(i, "term_id"), Code: ItemMissing})
|
|
case d.Action != "approve" && d.Action != "decline":
|
|
bad = append(bad, Item{Pointer: decisionPointer(i, "action"), Code: ItemMalformed})
|
|
case d.Action == "approve" && d.Dst == "":
|
|
// The conditional requirement of the schema, in the one place a generator cannot carry it:
|
|
// a signed term with an empty translation matches nothing yet reads as an intended
|
|
// rendering (canon §BankDecision).
|
|
bad = append(bad, Item{Pointer: decisionPointer(i, "dst"), Code: ItemMissing})
|
|
}
|
|
in = append(in, pgstore.BankDecision{TermID: d.TermID, Action: d.Action, Dst: d.Dst})
|
|
}
|
|
if len(bad) > 0 {
|
|
Invalid(w, r, bad...)
|
|
return
|
|
}
|
|
receipt, err := h.lib.SubmitBankDecisions(r.Context(), user, r.PathValue("bookId"), in, time.Now())
|
|
if err != nil {
|
|
h.fail(w, r, err)
|
|
return
|
|
}
|
|
h.writeJSON(w, r, http.StatusOK, wireBankReceipt{
|
|
Revision: receipt.Revision,
|
|
PendingDecisions: receipt.Counts.PendingDecisions,
|
|
Complete: receipt.Counts.Complete,
|
|
})
|
|
}
|
|
|
|
func decisionPointer(i int, field string) string {
|
|
return "/decisions/" + strconv.Itoa(i) + "/" + field
|
|
}
|