Artifact 5f7e3562172ba14f29bcf54f89ae653b68c448c78519988c16408b0ea2530009:
- File
internal/api/router.go
— part of check-in
[bf5ba9d2fc]
at
2026-08-16 17:07:31
on branch trunk
— oltp: accounts and per-repo roles
0001 §8's v2 tables. Accounts are the web/API identity (argon2id verifier, site-admin flag, read/write/admin per repo); the RFC §6.3 shared secret stays the sync credential, since the protocol fixes its derivation and the server must recompute it.
ops.Auth becomes the required role, so the Admin level that was already declared now means something. The gate stays open until the first account exists, mirroring the sync-user policy, so existing deployments and local single-user serving are unaffected. (user: dmestas, size: 4890) [annotate] [blame] [check-ins using]
// 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 ( "encoding/json" "errors" "fmt" "log" "mime" "net/http" "net/url" "reflect" "strings" "craftdesign.group/quarry/internal/ops" ) // 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) bool // 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) { 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)) } spec, err := ops.Spec(r, "quarry", version) if err != nil { return nil, err } specJSON, err := json.MarshalIndent(spec, "", " ") if err != nil { return nil, err } 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) http.Handler { wild := ops.Wildcards(d.Pattern) return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { if d.Auth != ops.Anonymous && verify != nil { user, pass, _ := req.BasicAuth() if !verify(req.PathValue("repo"), user, pass, d.Auth) { w.Header().Set("WWW-Authenticate", `Basic realm="quarry"`) writeError(w, http.StatusUnauthorized, errors.New("authentication required")) return } req = req.WithContext(ops.WithUser(req.Context(), user)) } in, err := decodeIn(req, d, wild) if err != nil { writeError(w, http.StatusBadRequest, err) return } if err := ops.Validate(in); err != nil { writeError(w, http.StatusBadRequest, err) 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.ErrUnavailable) { writeError(w, http.StatusServiceUnavailable, err) return } if err != nil { // The real error goes to the log; the public body never // carries internals (subprocess invocations, SQL, paths). log.Printf("api: %s: %v", d.Name, err) 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.DecodeValues(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") } 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()}) }