~bigbes/sr-ht-spec

ref: dd56e38c60d154ed0db6d60101a2fb92667438ea sr-ht-spec/api/api.go -rw-r--r-- 6.6 KiB
dd56e38c — Eugene Blikh refactor(doc): one route from a revision to an Archive (spec-wcr #2, #4) 25 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
// Package api is spec.sr.ht's REST write plane: the HTTP surface an agent PUTs
// a whole document to in order to open or extend a proposal.
//
// It is one of the two agent-facing write surfaces — the other is mcpsrv's
// spec_propose — and the design's rule is that both call the same
// service.Propose rather than each implementing If-Match, provenance and
// auto-merge for themselves. This package therefore holds no proposal logic: it
// parses an HTTP request into a service.ProposeRequest, calls through, and maps
// the result and the service's error sentinels onto status codes. A rule decided
// here that service/ did not would be exactly the drift the shared layer exists
// to prevent.
//
// # The contract
//
//	PUT /api/v1/spaces/~owner/name/docs/<path>
//	If-Match: <base-rev-sha>       # required: the approved head you read at
//	X-Proposal: <id>               # optional: add to this open proposal
//
// The body is the whole document. `If-Match` is the base B — the approved-head
// sha at the time the agent read — with one meaning shared across REST and MCP:
// opening cuts the branch from it, adding is validated against the proposal's
// fixed B. Title, rationale and the commit message ride in the query string, so
// the body stays the document and nothing else. Every response carries the
// proposal and its URL.
//
// # Who may write
//
// Proposing is agent-only; the human write path is native receive-pack. The
// endpoint installs authn's principal middleware so the bearer token resolves,
// and lets service.Propose refuse a non-agent — the ACL stays in service/,
// spelled once, rather than here and there.
package api

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"

	"sourcecraft.dev/bigbes/sr-ht-spec/authn"
	"sourcecraft.dev/bigbes/sr-ht-spec/service"
)

// maxBodyBytes caps the document a single PUT may carry. It is generous — a
// document is prose, not an upload — and exists only so a runaway or hostile
// client cannot make the daemon buffer an unbounded body into memory. gitx
// enforces its own per-blob limit on the commit; this is the earlier, cheaper
// refusal.
const maxBodyBytes = 5 << 20 // 5 MiB

// Writer is the write side of the orchestration layer this surface calls.
// *service.Service satisfies it, and it is the same method mcpsrv's spec_propose
// calls, which is what keeps the two write surfaces one implementation.
type Writer interface {
	Propose(ctx context.Context, req service.ProposeRequest) (service.ProposeResult, error)
}

// Options is everything a Server needs. New reports which one is missing rather
// than failing later inside a handler.
type Options struct {
	// Writer is the orchestration layer. *service.Service satisfies it.
	Writer Writer

	// Resolver turns an agent bearer token into a principal. Handler installs
	// its middleware; Register does not.
	Resolver *authn.Resolver
}

// Server is the REST write endpoint. It is built once at startup and is safe
// for concurrent use.
type Server struct {
	writer   Writer
	resolver *authn.Resolver
}

// New assembles the server over the seams in opts.
func New(opts Options) (*Server, error) {
	if opts.Writer == nil {
		return nil, fmt.Errorf("api: Writer is required")
	}
	if opts.Resolver == nil {
		return nil, fmt.Errorf("api: authn Resolver is required")
	}
	return &Server{writer: opts.Writer, resolver: opts.Resolver}, nil
}

// Handler returns the REST routes with panic recovery and authn's principal
// middleware installed, so it can be mounted on a router that has none:
//
//	router.Mount("/api", api.Handler())
//
// A caller whose router already resolves a principal uses Register instead;
// installing the middleware twice is harmless — it is idempotent.
func (s *Server) Handler() http.Handler {
	r := chi.NewRouter()
	r.Use(middleware.Recoverer)
	r.Use(s.resolver.Middleware())
	s.Register(r)
	return r
}

// Register mounts the write routes onto r. It installs no middleware of its own;
// the router it is handed must already resolve a principal into the request
// context (authn.Resolver.Middleware), or every writer looks anonymous and is
// refused.
//
// The document path is a single trailing wildcard: the space is "~owner/name"
// and everything after "/docs/" is the document's path in the tree, decoded per
// segment so a percent-encoded separator inside a name is not mistaken for one.
func (s *Server) Register(r chi.Router) {
	r.Put("/v1/spaces/~{owner}/{space}/docs/*", s.handlePut)
}

// proposeResponse is the JSON a successful write returns. It is the REST spelling
// of mcpsrv's proposeOutput — the same fields, so an agent switching surfaces
// reads the same answer — and it always carries the url, the whole point of the
// review plane's entry contract.
type proposeResponse struct {
	Proposal int    `json:"proposal"`
	URL      string `json:"url"`
	Merged   bool   `json:"merged"`
	State    string `json:"state"`
	Branch   string `json:"branch"`
	BaseRev  string `json:"base_rev"`
}

// writeJSON writes v as the response body with the given status. A failure to
// encode is logged into the void here — the header is already sent — but cannot
// be helped, so it is deliberately not retried into a second WriteHeader.
func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(v)
}

// writeError maps a service error onto a status code and a JSON body an agent
// can act on. The 4xx cases carry the service's message — the agent has to fix
// and retry, and "which document failed the schema" is the whole point — while
// a 5xx is a generic line, because an infrastructure failure's detail belongs in
// the daemon's log, not a client's error field.
func writeError(w http.ResponseWriter, err error) {
	status := statusFor(err)
	msg := err.Error()
	if status >= 500 {
		msg = "internal error"
	}
	writeJSON(w, status, map[string]string{"error": msg})
}

// statusFor maps the service sentinels onto HTTP. The staleness and
// already-merged cases are the design's 409; a malformed document is 422, kept
// distinct from the 403 of a principal that may not propose at all.
func statusFor(err error) int {
	switch {
	case errors.Is(err, service.ErrForbidden):
		return http.StatusForbidden
	case errors.Is(err, service.ErrInvalid):
		return http.StatusUnprocessableEntity
	case errors.Is(err, service.ErrStale),
		errors.Is(err, service.ErrAlreadyMerged),
		errors.Is(err, service.ErrProposalNotOpen):
		return http.StatusConflict
	case errors.Is(err, service.ErrNotFound):
		return http.StatusNotFound
	default:
		return http.StatusInternalServerError
	}
}