// router.go — the API consumer of the registry (0001 §9). Every registered
// op is mounted on a stdlib ServeMux; input crosses through the shared
// codecs and validator, output leaves as JSON. The generated OpenAPI
// document is served from the same registry at /api/v1/openapi.json.
package api
import (
"craftdesign.group/quarry/internal/errlog"
"context"
"encoding/json"
"errors"
"fmt"
"log"
"mime"
"net/http"
"net/url"
"reflect"
"strings"
"craftdesign.group/quarry/internal/ops"
"craftdesign.group/quarry/internal/fault"
)
// Verify checks one repo credential pair against the capability the op
// requires. A nil Verify leaves every gated op open (single-user local
// serving); the composition root supplies the OLTP-backed implementation.
// need is the op's own ops.Auth, so one seam serves both the write ops
// (Token) and the administrative ones (Admin) without the router
// deciding what either means.
type Verify func(repo, user, password string, need ops.Auth) Decision
// CapSet is the expanded capability set for one (account, repo). It
// mirrors oltp.CapSet without importing it — the router stays free of
// storage types.
type CapSet interface{ HasAny(letters string) bool }
// CapsFor resolves effective capabilities. nil disables cap enforcement
// in the router (the Auth floor still applies), which is what a forge
// with no accounts plane wants.
type CapsFor func(account, repo string) CapSet
// Decision is what a credential check concluded. Three outcomes rather
// than a bool because two of them are not the same thing and the wire
// has distinct codes for them: a client told 401 retries with the same
// credentials forever, when what it needed to hear was that the
// credentials were fine and the capability was not.
type Decision int
const (
// Denied: absent or wrong credentials. 401, with a challenge.
Denied Decision = iota
// Insufficient: authenticated, but lacking the capability. 403, and
// deliberately WITHOUT a challenge — re-authenticating cannot help.
Insufficient
// Allowed.
Allowed
)
// Router mounts every op in r plus the OpenAPI document. version flows
// into the spec's info block. verify (optional) gates ops.Token ops
// with HTTP Basic against it.
func Router(r *ops.Registry, version string, verify ...Verify) (http.Handler, error) {
return RouterWithCaps(r, version, nil, verify...)
}
// RouterWithCaps is Router plus capability enforcement (RFC 0004).
func RouterWithCaps(r *ops.Registry, version string, capsFor CapsFor, verify ...Verify) (http.Handler, error) {
var v Verify
if len(verify) > 0 {
v = verify[0]
}
mux := http.NewServeMux()
for _, d := range r.All() {
mux.Handle(d.Method+" "+d.Pattern, opHandler(r, d, v, capsFor))
}
spec, err := ops.Spec(r, "quarry", version)
if err != nil {
return nil, err
}
specJSON, err := json.MarshalIndent(spec, "", " ")
if err != nil {
return nil, err
}
// The service worker (RFC 0011 §6). Registered here so a node that
// mounts this router at the root serves it without extra wiring;
// the composition root mounts it at "/" as well, because quarry's
// own server mounts this router under /api/ and a worker served
// from there could not claim the site's scope.
mux.Handle("GET /sw.js", ServiceWorker())
mux.HandleFunc("GET /api/v1/openapi.json", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write(specJSON)
})
// Unmatched API paths answer in the API's own dialect, never Go's
// plain-text default.
mux.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) {
writeError(w, http.StatusNotFound, errors.New("no such endpoint"))
})
return mux, nil
}
func opHandler(r *ops.Registry, d *ops.Descriptor, verify Verify, capsFor CapsFor) http.Handler {
wild := ops.Wildcards(d.Pattern)
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
// Resolve identity even for ANONYMOUS ops. A private repository
// is readable by the people with a role on it, and a read op
// that never looks at credentials cannot tell those people from
// strangers — so its owner would be locked out of their own
// repository.
if d.Auth == ops.Anonymous && verify != nil {
if user, pass, ok := req.BasicAuth(); ok && user != "" {
if verify(req.PathValue("repo"), user, pass, ops.Anonymous) == Allowed {
req = req.WithContext(ops.WithUser(req.Context(), user))
}
}
}
if d.Auth != ops.Anonymous && verify != nil {
user, pass, _ := req.BasicAuth()
switch verify(req.PathValue("repo"), user, pass, d.Auth) {
case Denied:
w.Header().Set("WWW-Authenticate", `Basic realm="quarry"`)
writeError(w, http.StatusUnauthorized, errors.New("authentication required"))
return
case Insufficient:
writeError(w, http.StatusForbidden,
errors.New("this account does not have the capability this request needs"))
return
}
req = req.WithContext(ops.WithUser(req.Context(), user))
}
// The capability refinement (RFC 0004 §4): Auth was the coarse
// floor, Caps is what this SURFACE needs. Unwired (nil) means
// caps are not enforced here — the floor still stands.
if d.Caps != "" && capsFor != nil {
// Most ops name their repository in the path; a few (the
// repos/* family) carry it as a field instead, and caps
// resolved against "" would silently refuse everyone.
repo := req.PathValue("repo")
if repo == "" {
if err := req.ParseForm(); err == nil {
repo = req.Form.Get("repo")
if repo == "" {
repo = req.Form.Get("name")
}
}
}
c := capsFor(ops.User(req.Context()), repo)
if !c.HasAny(d.Caps) {
writeError(w, http.StatusForbidden,
errors.New("this account does not have the capability this request needs"))
return
}
// Carried on, not discarded: Caps answered "may they reach
// this surface", and an op may still need to know WHICH
// letters they hold to decide what the surface shows.
req = req.WithContext(ops.WithCaps(req.Context(), c))
}
in, err := decodeIn(req, d, wild)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if leid := req.Header.Get("Last-Event-ID"); leid != "" && req.URL.Query().Get("from") == "" {
// A reconnecting SSE client resends its cursor in the
// header, not the query string (Datastar's fetch.ts and
// every other EventSource client agree on this). Overlay
// it onto whichever field an op's In struct names "from" —
// unknown-key overlays are a no-op, so this is harmless on
// every non-streaming op too.
//
// An explicit ?from= in the query wins: it is the caller
// stating a cursor for THIS request, while the header is a
// cursor the browser remembered from a connection that has
// since dropped. Silently overwriting the former with the
// latter makes an explicit resume unrepeatable.
ptr := reflect.New(reflect.TypeOf(in))
ptr.Elem().Set(reflect.ValueOf(in))
if err := ops.OverlayValues(map[string][]string{"from": {leid}}, ptr.Interface()); err == nil {
in = deref(ptr.Interface())
}
}
if err := ops.Validate(in); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if d.Kind == ops.Stream {
serveStream(w, req, r, d, in)
return
}
out, err := r.Invoke(req.Context(), d.Name, in)
if errors.Is(err, ops.ErrNotFound) {
writeError(w, http.StatusNotFound, err)
return
}
if errors.Is(err, ops.ErrInvalid) {
writeError(w, http.StatusBadRequest, err)
return
}
if errors.Is(err, ops.ErrForbidden) {
writeError(w, http.StatusForbidden, err)
return
}
if errors.Is(err, ops.ErrUnavailable) {
writeError(w, http.StatusServiceUnavailable, err)
return
}
if err != nil {
// The real error goes to the log AND the journal; the public
// body never carries internals (subprocess invocations, SQL,
// paths). The journal is what makes "details are in the
// server log" a promise someone can actually collect on.
log.Printf("api: %s: %v", d.Name, err)
errlog.Default.ReportCtx(req.Context(), "api", d.Name, req.PathValue("repo"), "",
fault.Wrap(err, "an API operation failed").
Cost("this caller got a 500; the public body says nothing, so the journal is the only account of why").
With("op", d.Name, "method", req.Method, "path", req.URL.Path))
writeError(w, http.StatusInternalServerError, errors.New("internal error"))
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(out)
})
}
// decodeIn assembles the op's In struct from the request: query string
// (GET) or body (POST, form or JSON by content type), with path wildcards
// merged last so the routed path is always authoritative.
func decodeIn(req *http.Request, d *ops.Descriptor, wild []string) (any, error) {
inPtr := d.NewIn()
switch {
case d.Method == http.MethodGet:
if err := ops.DecodeValues(req.URL.Query(), inPtr); err != nil {
return nil, err
}
case isJSON(req):
if err := json.NewDecoder(req.Body).Decode(inPtr); err != nil {
return nil, fmt.Errorf("body: %w", err)
}
default:
if err := req.ParseForm(); err != nil {
return nil, fmt.Errorf("form: %w", err)
}
if err := ops.DecodeValues(req.PostForm, inPtr); err != nil {
return nil, err
}
}
if len(wild) > 0 {
pathVals := url.Values{}
for _, name := range wild {
pathVals.Set(name, req.PathValue(name))
}
if err := ops.OverlayValues(pathVals, inPtr); err != nil {
return nil, err
}
}
return deref(inPtr), nil
}
func deref(p any) any {
return reflect.ValueOf(p).Elem().Interface()
}
func isJSON(req *http.Request) bool {
ct, _, _ := mime.ParseMediaType(req.Header.Get("Content-Type"))
return strings.HasSuffix(ct, "/json") || strings.HasSuffix(ct, "+json")
}
// serveStream switches the response to Server-Sent Events and runs a
// Stream op's StreamRun until the client disconnects (req.Context()
// done) or the op itself returns. Headers first, exactly once — an op
// that errors mid-stream cannot say so any other way than closing.
func serveStream(w http.ResponseWriter, req *http.Request, r *ops.Registry, d *ops.Descriptor, in any) {
fl, ok := w.(http.Flusher)
if !ok {
writeError(w, http.StatusInternalServerError, errors.New("streaming unsupported"))
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, ": live\n\n")
fl.Flush()
st := &sseStreamer{w: w, fl: fl}
if err := r.InvokeStream(req.Context(), d.Name, in, st); err != nil && !errors.Is(err, context.Canceled) {
log.Printf("api: %s: stream: %v", d.Name, err)
}
}
// sseStreamer is the router's ops.Streamer: it writes wire-format SSE
// frames and flushes after every one, since a buffered frame is a
// delivered message a client hasn't seen yet.
type sseStreamer struct {
w http.ResponseWriter
fl http.Flusher
}
func (s *sseStreamer) Emit(id uint64, data any) error {
b, err := json.Marshal(data)
if err != nil {
return err
}
if id != 0 {
fmt.Fprintf(s.w, "id: %d\n", id)
}
fmt.Fprintf(s.w, "data: %s\n\n", b)
s.fl.Flush()
return nil
}
func (s *sseStreamer) Event(name string, data any) error {
b, err := json.Marshal(data)
if err != nil {
return err
}
fmt.Fprintf(s.w, "event: %s\n", name)
fmt.Fprintf(s.w, "data: %s\n\n", b)
s.fl.Flush()
return nil
}
func (s *sseStreamer) Ping() {
fmt.Fprint(s.w, ": ping\n\n")
s.fl.Flush()
}
func writeError(w http.ResponseWriter, code int, err error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
}