coverage~bigbes/sr-ht-spec3cb1c03dapi/propose.go

Coverage
85.7% 36/42 statements
Δ
Blob
d28d15d
1 package api
2
3 import (
4 "io"
5 "net/http"
6 "net/url"
7 "strconv"
8 "strings"
9
10 "github.com/go-chi/chi/v5"
11
12 "sourcecraft.dev/bigbes/sr-ht-spec/authn"
13 "sourcecraft.dev/bigbes/sr-ht-spec/core"
14 "sourcecraft.dev/bigbes/sr-ht-spec/service"
15 )
16
17 // handlePut is the write plane's one handler: PUT a whole document to open or
18 // extend a proposal.
19 //
20 // It parses the request into a service.ProposeRequest and calls through — the
21 // document path from the URL, the base from If-Match, the optional target
22 // proposal from X-Proposal, the title/rationale/message from the query string,
23 // and the document from the body. The status is 201 when a new proposal was
24 // opened and 200 when documents were added to an existing one; the merged field
25 // says whether policy landed it immediately.
26 13 func (s *Server) handlePut(w http.ResponseWriter, r *http.Request) {
27 13 ref := core.SpaceRef{Owner: chi.URLParam(r, "owner"), Name: chi.URLParam(r, "space")}
28 13
29 13 docPath, ok := unescapePath(chi.URLParam(r, "*"))
30 13 if !ok || docPath == "" {
31 0 writeJSON(w, http.StatusBadRequest, map[string]string{
32 0 "error": "the document path after /docs/ is missing or malformed",
33 0 })
34 0 return
35 0 }
36
37 13 base := strings.TrimSpace(r.Header.Get("If-Match"))
38 13 if base == "" {
39 1 writeJSON(w, http.StatusBadRequest, map[string]string{
40 1 "error": "If-Match is required: send the approved-head revision you read at, so the " +
41 1 "proposal has a base and a concurrent change cannot be clobbered",
42 1 })
43 1 return
44 1 }
45
46 12 proposalID, ok := parseProposalHeader(r.Header.Get("X-Proposal"))
47 12 if !ok {
48 1 writeJSON(w, http.StatusBadRequest, map[string]string{
49 1 "error": "X-Proposal must be a positive proposal id; omit it to open a new proposal",
50 1 })
51 1 return
52 1 }
53
54 11 body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBodyBytes))
55 11 if err != nil {
56 0 // MaxBytesReader signals an over-limit body through a read error; there
57 0 // is no way to tell it from a truncated client here, so both are 413.
58 0 writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{
59 0 "error": "the document body could not be read or exceeds the size limit",
60 0 })
61 0 return
62 0 }
63
64 // The acting agent is resolved from the bearer token by the middleware.
65 // service.Propose refuses a non-agent, so an anonymous caller is a 403 there
66 // rather than a check duplicated here.
67 11 principal := authn.PrincipalFromContext(r.Context())
68 11
69 11 res, err := s.writer.Propose(r.Context(), service.ProposeRequest{
70 11 Space: ref,
71 11 Principal: principal,
72 11 ProposalID: proposalID,
73 11 Title: strings.TrimSpace(r.URL.Query().Get("title")),
74 11 Rationale: strings.TrimSpace(r.URL.Query().Get("rationale")),
75 11 IfMatch: base,
76 11 Message: strings.TrimSpace(r.URL.Query().Get("message")),
77 11 Writes: []service.DocumentWrite{{Path: docPath, Content: body}},
78 11 })
79 11 if err != nil {
80 7 writeError(w, err)
81 7 return
82 7 }
83
84 4 status := http.StatusOK
85 4 if proposalID == 0 {
86 3 status = http.StatusCreated
87 3 }
88 4 writeJSON(w, status, proposeResponse{
89 4 Proposal: res.Proposal.ID,
90 4 URL: res.URL,
91 4 Merged: res.Merged,
92 4 State: string(res.Proposal.State),
93 4 Branch: res.Proposal.Branch,
94 4 BaseRev: res.Proposal.BaseRev,
95 4 })
96 }
97
98 // parseProposalHeader reads the optional X-Proposal header. An empty header
99 // means "open a new proposal" and is valid; a present value must be a positive
100 // integer. It returns the id and whether the header was well-formed.
101 12 func parseProposalHeader(v string) (int, bool) {
102 12 v = strings.TrimSpace(v)
103 12 if v == "" {
104 10 return 0, true
105 10 }
106 2 id, err := strconv.Atoi(v)
107 2 if err != nil || id <= 0 {
108 1 return 0, false
109 1 }
110 1 return id, true
111 }
112
113 // unescapePath decodes a chi trailing wildcard back into a document path,
114 // per segment.
115 //
116 // chi routes on the raw (percent-encoded) path when the request had one, so a
117 // document whose name carries a space or a Cyrillic letter arrives encoded.
118 // Decoding per segment is deliberate: a %2F inside a segment is a literal slash
119 // in a name, not a path separator, and joining decoded segments with "/" keeps
120 // it from becoming one. It mirrors web/'s unescapePath so the read and write
121 // surfaces address a document by exactly the same path grammar.
122 13 func unescapePath(raw string) (string, bool) {
123 13 if raw == "" {
124 0 return "", true
125 0 }
126 13 segs := strings.Split(raw, "/")
127 26 for i, seg := range segs {
128 26 dec, err := url.PathUnescape(seg)
129 26 if err != nil {
130 0 return "", false
131 0 }
132 26 segs[i] = dec
133 }
134 13 return strings.Join(segs, "/"), true
135 }
136
137 // compile-time assertion that the production service satisfies the write this
138 // surface needs.
139 var _ Writer = (*service.Service)(nil)