Artifact 94277f7916bd83f2b1a598ba82d2c7c34cd342a1cf4ff963feafaddea9c32f39:
- File
internal/api/register.go
— part of check-in
[f073a79918]
at
2026-08-15 16:10:40
on branch trunk
— error taxonomy: 404s, themed error pages, message hygiene (tickets 020/040/070/090)
ops.ErrNotFound sentinel; router maps to 404, hides internals on 500; JSON 404 for unmatched API paths; themed web 404/500; zitcli.ErrNoEntry; clean codec parse messages; shunned-diff note. (user: dmestas, size: 13977) [annotate] [blame] [check-ins using]
// Package api owns RegisterAll — the one explicit wiring point where every // op joins the registry (0003 §2: no init() magic; the wiring stays // greppable) — plus the HTTP router that serves the registered ops. This // is the sanctioned place Run closures over the data planes are built; // the arch gate keeps every other consumer on the op side of the seam. package api import ( "bytes" "context" "errors" "fmt" "html" "unicode/utf8" "craftdesign.group/quarry/internal/highlight" "craftdesign.group/quarry/internal/index" "craftdesign.group/quarry/internal/markup" "craftdesign.group/quarry/internal/ops" "craftdesign.group/quarry/internal/zitcli" ) // Deps carries what op implementations close over. Index may be nil (no // store configured — `quarry spec` runs this way); ops that need it // answer with an error at run time, and the spec stays complete either // way. type Deps struct { Version string Index *index.Index Zit *zitcli.Client // on-demand store reads (file lists, cat, diff) } type versionIn struct{} type versionOut struct { Version string `json:"version"` } type timelineIn struct { Limit int `json:"limit" validate:"min=0,max=200"` } type timelineOut struct { Entries []index.TimelineEntry `json:"entries"` Count int `json:"count"` } type wikiListIn struct{} type wikiListOut struct { Pages []index.WikiTitle `json:"pages"` Count int `json:"count"` } type wikiGetIn struct { Name string `json:"name" validate:"required,max=100"` } type wikiGetOut struct { Title string `json:"title"` Date string `json:"date"` User string `json:"user"` Mimetype string `json:"mimetype"` Source string `json:"source"` HTML string `json:"html"` } type fileGetIn struct { Hash string `json:"hash" validate:"required,min=4,max=64"` Path string `json:"path" validate:"required,max=512"` } type fileGetOut struct { Hash string `json:"hash"` Path string `json:"path"` Size int `json:"size"` Binary bool `json:"binary"` Content string `json:"content"` HTML string `json:"html"` // highlighted, sanitizer-gated; empty when no lexer matched } type searchIn struct { Q string `json:"q" validate:"required,max=200"` Limit int `json:"limit" validate:"min=0,max=200"` } type searchOut struct { Results []index.SearchResult `json:"results"` Count int `json:"count"` } type diffGetIn struct { Hash string `json:"hash" validate:"required,min=4,max=64"` } type diffGetOut struct { From string `json:"from"` To string `json:"to"` Files []zitcli.DiffEntry `json:"files"` Unified string `json:"unified"` Note string `json:"note"` // set when the diff could not be fully computed } type checkinGetIn struct { Hash string `json:"hash" validate:"required,min=4,max=64"` } type checkinGetOut struct { Hash string `json:"hash"` Date string `json:"date"` User string `json:"user"` Comment string `json:"comment"` Parents []string `json:"parents"` Files []zitcli.LsEntry `json:"files"` } type ticketListIn struct{} // TicketSummary is the list row: id plus the two fields every tracker // listing needs, pulled out of the folded state. type ticketSummary struct { ID string `json:"id"` Title string `json:"title"` Status string `json:"status"` Updated string `json:"updated"` } type ticketListOut struct { Tickets []ticketSummary `json:"tickets"` Count int `json:"count"` } type ticketGetIn struct { ID string `json:"id" validate:"required,min=4,max=40"` } type ticketGetOut struct { Ticket index.TicketState `json:"ticket"` } func fieldValue(fields []index.TicketFieldSet, name string) string { for _, f := range fields { if f.Name == name { return f.Value } } return "" } // resolver implements markup.Resolver over the index. Wiki names resolve // to existing pages; hash prefixes resolve to uniquely matching // check-ins. An unresolvable or ambiguous reference renders as plain // text, never as a dead link. type resolver struct{ ix *index.Index } func (r resolver) ResolveHash(prefix string) string { if r.ix == nil { return "" } if e, _, ok, err := r.ix.CheckinByPrefix(prefix); err == nil && ok { return "/checkin/" + e.Hash } if t, ok, err := r.ix.TicketByPrefix(prefix); err == nil && ok { return "/ticket/" + t.ID } return "" } func (r resolver) ResolveWiki(name string) string { if r.ix == nil { return "" } if _, ok, err := r.ix.WikiPage(name); err == nil && ok { return "/wiki/" + name } return "" } // RegisterAll wires every op. func RegisterAll(r *ops.Registry, d Deps) error { if err := ops.Register(r, ops.Op[versionIn, versionOut]{ Name: "meta.version", Doc: "Report the quarry build version.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/version", Run: func(_ context.Context, _ versionIn) (versionOut, error) { return versionOut{Version: d.Version}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[timelineIn, timelineOut]{ Name: "timeline.list", Doc: "List check-ins, newest first.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/timeline", Run: func(_ context.Context, in timelineIn) (timelineOut, error) { if d.Index == nil { return timelineOut{}, fmt.Errorf("no store is being served") } limit := in.Limit if limit == 0 { limit = 50 } entries, err := d.Index.Timeline(limit) if err != nil { return timelineOut{}, err } return timelineOut{Entries: entries, Count: len(entries)}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[wikiListIn, wikiListOut]{ Name: "wiki.list", Doc: "List wiki pages with their latest edit.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/wiki", Run: func(_ context.Context, _ wikiListIn) (wikiListOut, error) { if d.Index == nil { return wikiListOut{}, fmt.Errorf("no store is being served") } pages, err := d.Index.WikiTitles() if err != nil { return wikiListOut{}, err } return wikiListOut{Pages: pages, Count: len(pages)}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[ticketListIn, ticketListOut]{ Name: "ticket.list", Doc: "List tickets with their folded current state.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/ticket", Run: func(_ context.Context, _ ticketListIn) (ticketListOut, error) { if d.Index == nil { return ticketListOut{}, fmt.Errorf("no store is being served") } all, err := d.Index.Tickets() if err != nil { return ticketListOut{}, err } out := ticketListOut{Count: len(all)} for _, t := range all { out.Tickets = append(out.Tickets, ticketSummary{ ID: t.ID, Title: fieldValue(t.Fields, "title"), Status: fieldValue(t.Fields, "status"), Updated: t.Updated, }) } return out, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[ticketGetIn, ticketGetOut]{ Name: "ticket.get", Doc: "Fetch one ticket: folded state plus change history.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/ticket/{id}", Run: func(_ context.Context, in ticketGetIn) (ticketGetOut, error) { if d.Index == nil { return ticketGetOut{}, fmt.Errorf("no store is being served") } t, ok, err := d.Index.TicketByPrefix(in.ID) if err != nil { return ticketGetOut{}, err } if !ok { return ticketGetOut{}, fmt.Errorf("no unique ticket for %q: %w", in.ID, ops.ErrNotFound) } return ticketGetOut{Ticket: t}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[searchIn, searchOut]{ Name: "search.query", Doc: "Full-text search over check-ins, wiki, and tickets.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/search", Run: func(_ context.Context, in searchIn) (searchOut, error) { if d.Index == nil { return searchOut{}, fmt.Errorf("no store is being served") } limit := in.Limit if limit == 0 { limit = 50 } results, err := d.Index.Search(in.Q, limit) if err != nil { return searchOut{}, err } return searchOut{Results: results, Count: len(results)}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[diffGetIn, diffGetOut]{ Name: "checkin.diff", Doc: "Diff one check-in against its primary parent.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/checkin/{hash}/diff", Run: func(_ context.Context, in diffGetIn) (diffGetOut, error) { // Resolve the entity before the Zit-configured check so an // unknown hash is a 404 in every configuration. if d.Index == nil { return diffGetOut{}, fmt.Errorf("no store is being served") } e, parents, ok, err := d.Index.CheckinByPrefix(in.Hash) if err != nil { return diffGetOut{}, err } if !ok { return diffGetOut{}, fmt.Errorf("no unique check-in for %q: %w", in.Hash, ops.ErrNotFound) } if d.Zit == nil { return diffGetOut{}, fmt.Errorf("no store is being served") } out := diffGetOut{To: e.Hash} if len(parents) == 0 { // A root check-in has nothing to diff against; its // whole file set is the change. files, err := d.Zit.Ls(e.Hash) if err != nil { return diffGetOut{}, err } for _, f := range files { out.Files = append(out.Files, zitcli.DiffEntry{Status: "A", Name: f.Name}) } return out, nil } out.From = parents[0] unified, brief, err := d.Zit.Diff(out.From, e.Hash) if err != nil { return diffGetOut{}, err } out.Unified, out.Files = unified, brief // zit exits 0 with empty output when changed content is // unavailable (shunned) — flag it rather than serving what // looks like a legitimate no-op diff. if out.Unified == "" && len(out.Files) > 0 { out.Note = "diff content unavailable — the changed file content may have been shunned from the store" } return out, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[fileGetIn, fileGetOut]{ Name: "file.get", Doc: "Fetch one file's content at a check-in.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/file/{hash}/{path...}", Run: func(_ context.Context, in fileGetIn) (fileGetOut, error) { if d.Index == nil { return fileGetOut{}, fmt.Errorf("no store is being served") } e, _, ok, err := d.Index.CheckinByPrefix(in.Hash) if err != nil { return fileGetOut{}, err } if !ok { return fileGetOut{}, fmt.Errorf("no unique check-in for %q: %w", in.Hash, ops.ErrNotFound) } if d.Zit == nil { return fileGetOut{}, fmt.Errorf("no store is being served") } raw, err := d.Zit.Cat(in.Path, e.Hash) if errors.Is(err, zitcli.ErrNoEntry) { // Clean public message; the zit detail stays behind. return fileGetOut{}, fmt.Errorf("no file %q at this check-in: %w", in.Path, ops.ErrNotFound) } if err != nil { return fileGetOut{}, err } out := fileGetOut{Hash: e.Hash, Path: in.Path, Size: len(raw)} // Binary content stays out of the JSON surface; the page // says "binary" and the artifact read verb serves bytes. if utf8.Valid(raw) && !bytes.ContainsRune(raw, 0) { out.Content = string(raw) // Best-effort highlighting; sanitized like all rendered // HTML even though quarry generated it. if hl, ok := highlight.Code(raw, "", in.Path); ok { out.HTML = markup.Sanitize(hl) } } else { out.Binary = true } return out, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[checkinGetIn, checkinGetOut]{ Name: "checkin.get", Doc: "Describe one check-in: metadata, parents, and its file set.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/checkin/{hash}", Run: func(_ context.Context, in checkinGetIn) (checkinGetOut, error) { if d.Index == nil { return checkinGetOut{}, fmt.Errorf("no store is being served") } e, parents, ok, err := d.Index.CheckinByPrefix(in.Hash) if err != nil { return checkinGetOut{}, err } if !ok { return checkinGetOut{}, fmt.Errorf("no unique check-in for %q: %w", in.Hash, ops.ErrNotFound) } out := checkinGetOut{ Hash: e.Hash, Date: e.Date, User: e.User, Comment: e.Comment, Parents: parents, } // File set is an on-demand zit read (0001 §2), not index // state — the index answers "which check-in", the store // answers "what does it hold". if d.Zit != nil { files, err := d.Zit.Ls(e.Hash) if err != nil { return checkinGetOut{}, err } out.Files = files } return out, nil }, }); err != nil { return err } return ops.Register(r, ops.Op[wikiGetIn, wikiGetOut]{ Name: "wiki.get", Doc: "Fetch one wiki page, rendered.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/wiki/{name}", Run: func(_ context.Context, in wikiGetIn) (wikiGetOut, error) { if d.Index == nil { return wikiGetOut{}, fmt.Errorf("no store is being served") } page, ok, err := d.Index.WikiPage(in.Name) if err != nil { return wikiGetOut{}, err } if !ok { return wikiGetOut{}, fmt.Errorf("no wiki page %q: %w", in.Name, ops.ErrNotFound) } var rendered []byte switch page.Mimetype { case "", "text/x-markdown", "text/markdown": rendered, err = markup.Render(page.Body, resolver{ix: d.Index}) if err != nil { return wikiGetOut{}, err } default: // Non-markdown wiki content renders as escaped text. rendered = []byte("<pre>" + html.EscapeString(string(page.Body)) + "</pre>") } return wikiGetOut{ Title: page.Title, Date: page.Date, User: page.User, Mimetype: page.Mimetype, Source: string(page.Body), HTML: string(rendered), }, nil }, }) }