coverage~bigbes/sr-ht-spec64cae3afapi/api.go

Coverage
84.0% 21/25 statements
Δ
+0.0
Blob
b1e6923
1 // Package api is spec.sr.ht's REST write plane: the HTTP surface an agent PUTs
2 // a whole document to in order to open or extend a proposal.
3 //
4 // It is one of the two agent-facing write surfaces — the other is mcpsrv's
5 // spec_propose — and the design's rule is that both call the same
6 // service.Propose rather than each implementing If-Match, provenance and
7 // auto-merge for themselves. This package therefore holds no proposal logic: it
8 // parses an HTTP request into a service.ProposeRequest, calls through, and maps
9 // the result and the service's error sentinels onto status codes. A rule decided
10 // here that service/ did not would be exactly the drift the shared layer exists
11 // to prevent.
12 //
13 // # The contract
14 //
15 // PUT /api/v1/spaces/~owner/name/docs/<path>
16 // If-Match: <base-rev-sha> # required: the approved head you read at
17 // X-Proposal: <id> # optional: add to this open proposal
18 //
19 // The body is the whole document. `If-Match` is the base B — the approved-head
20 // sha at the time the agent read — with one meaning shared across REST and MCP:
21 // opening cuts the branch from it, adding is validated against the proposal's
22 // fixed B. Title, rationale and the commit message ride in the query string, so
23 // the body stays the document and nothing else. Every response carries the
24 // proposal and its URL.
25 //
26 // # Who may write
27 //
28 // Proposing is agent-only; the human write path is native receive-pack. The
29 // endpoint installs authn's principal middleware so the bearer token resolves,
30 // and lets service.Propose refuse a non-agent — the ACL stays in service/,
31 // spelled once, rather than here and there.
32 package api
33
34 import (
35 "context"
36 "encoding/json"
37 "errors"
38 "fmt"
39 "net/http"
40
41 "github.com/go-chi/chi/v5"
42 "github.com/go-chi/chi/v5/middleware"
43
44 "sourcecraft.dev/bigbes/sr-ht-spec/authn"
45 "sourcecraft.dev/bigbes/sr-ht-spec/service"
46 )
47
48 // maxBodyBytes caps the document a single PUT may carry. It is generous — a
49 // document is prose, not an upload — and exists only so a runaway or hostile
50 // client cannot make the daemon buffer an unbounded body into memory. gitx
51 // enforces its own per-blob limit on the commit; this is the earlier, cheaper
52 // refusal.
53 const maxBodyBytes = 5 << 20 // 5 MiB
54
55 // Writer is the write side of the orchestration layer this surface calls.
56 // *service.Service satisfies it, and it is the same method mcpsrv's spec_propose
57 // calls, which is what keeps the two write surfaces one implementation.
58 type Writer interface {
59 Propose(ctx context.Context, req service.ProposeRequest) (service.ProposeResult, error)
60 }
61
62 // Options is everything a Server needs. New reports which one is missing rather
63 // than failing later inside a handler.
64 type Options struct {
65 // Writer is the orchestration layer. *service.Service satisfies it.
66 Writer Writer
67
68 // Resolver turns an agent bearer token into a principal. Handler installs
69 // its middleware; Register does not.
70 Resolver *authn.Resolver
71 }
72
73 // Server is the REST write endpoint. It is built once at startup and is safe
74 // for concurrent use.
75 type Server struct {
76 writer Writer
77 resolver *authn.Resolver
78 }
79
80 // New assembles the server over the seams in opts.
81 17 func New(opts Options) (*Server, error) {
82 17 if opts.Writer == nil {
83 0 return nil, fmt.Errorf("api: Writer is required")
84 0 }
85 17 if opts.Resolver == nil {
86 0 return nil, fmt.Errorf("api: authn Resolver is required")
87 0 }
88 17 return &Server{writer: opts.Writer, resolver: opts.Resolver}, nil
89 }
90
91 // Handler returns the REST routes with panic recovery and authn's principal
92 // middleware installed, so it can be mounted on a router that has none:
93 //
94 // router.Mount("/api", api.Handler())
95 //
96 // A caller whose router already resolves a principal uses Register instead;
97 // installing the middleware twice is harmless — it is idempotent.
98 6 func (s *Server) Handler() http.Handler {
99 6 r := chi.NewRouter()
100 6 r.Use(middleware.Recoverer)
101 6 r.Use(s.resolver.Middleware())
102 6 s.Register(r)
103 6 return r
104 6 }
105
106 // Register mounts the write routes onto r. It installs no middleware of its own;
107 // the router it is handed must already resolve a principal into the request
108 // context (authn.Resolver.Middleware), or every writer looks anonymous and is
109 // refused.
110 //
111 // The document path is a single trailing wildcard: the space is "~owner/name"
112 // and everything after "/docs/" is the document's path in the tree, decoded per
113 // segment so a percent-encoded separator inside a name is not mistaken for one.
114 17 func (s *Server) Register(r chi.Router) {
115 17 r.Put("/v1/spaces/~{owner}/{space}/docs/*", s.handlePut)
116 17 }
117
118 // proposeResponse is the JSON a successful write returns. It is the REST spelling
119 // of mcpsrv's proposeOutput — the same fields, so an agent switching surfaces
120 // reads the same answer — and it always carries the url, the whole point of the
121 // review plane's entry contract.
122 type proposeResponse struct {
123 Proposal int `json:"proposal"`
124 URL string `json:"url"`
125 Merged bool `json:"merged"`
126 State string `json:"state"`
127 Branch string `json:"branch"`
128 BaseRev string `json:"base_rev"`
129 }
130
131 // writeJSON writes v as the response body with the given status. A failure to
132 // encode is logged into the void here — the header is already sent — but cannot
133 // be helped, so it is deliberately not retried into a second WriteHeader.
134 13 func writeJSON(w http.ResponseWriter, status int, v any) {
135 13 w.Header().Set("Content-Type", "application/json; charset=utf-8")
136 13 w.WriteHeader(status)
137 13 _ = json.NewEncoder(w).Encode(v)
138 13 }
139
140 // writeError maps a service error onto a status code and a JSON body an agent
141 // can act on. The 4xx cases carry the service's message — the agent has to fix
142 // and retry, and "which document failed the schema" is the whole point — while
143 // a 5xx is a generic line, because an infrastructure failure's detail belongs in
144 // the daemon's log, not a client's error field.
145 7 func writeError(w http.ResponseWriter, err error) {
146 7 status := statusFor(err)
147 7 msg := err.Error()
148 7 if status >= 500 {
149 0 msg = "internal error"
150 0 }
151 7 writeJSON(w, status, map[string]string{"error": msg})
152 }
153
154 // statusFor maps the service sentinels onto HTTP. The staleness and
155 // already-merged cases are the design's 409; a malformed document is 422, kept
156 // distinct from the 403 of a principal that may not propose at all.
157 7 func statusFor(err error) int {
158 7 switch {
159 2 case errors.Is(err, service.ErrForbidden):
160 2 return http.StatusForbidden
161 1 case errors.Is(err, service.ErrInvalid):
162 1 return http.StatusUnprocessableEntity
163 case errors.Is(err, service.ErrStale),
164 errors.Is(err, service.ErrAlreadyMerged),
165 3 errors.Is(err, service.ErrProposalNotOpen):
166 3 return http.StatusConflict
167 1 case errors.Is(err, service.ErrNotFound):
168 1 return http.StatusNotFound
169 0 default:
170 0 return http.StatusInternalServerError
171 }
172 }