~bigbes/sr-ht-spec

ref: f42f81ca17c2a1a3da14002e77562b51df14b34d sr-ht-spec/api/propose.go -rw-r--r-- 4.4 KiB
f42f81ca — Eugene Blikh feat(web): line-numbered unified prose diff replaces the block cards (spec-by6.3.5) 13 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
package api

import (
	"io"
	"net/http"
	"net/url"
	"strconv"
	"strings"

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

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

// handlePut is the write plane's one handler: PUT a whole document to open or
// extend a proposal.
//
// It parses the request into a service.ProposeRequest and calls through — the
// document path from the URL, the base from If-Match, the optional target
// proposal from X-Proposal, the title/rationale/message from the query string,
// and the document from the body. The status is 201 when a new proposal was
// opened and 200 when documents were added to an existing one; the merged field
// says whether policy landed it immediately.
func (s *Server) handlePut(w http.ResponseWriter, r *http.Request) {
	ref := core.SpaceRef{Owner: chi.URLParam(r, "owner"), Name: chi.URLParam(r, "space")}

	docPath, ok := unescapePath(chi.URLParam(r, "*"))
	if !ok || docPath == "" {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "the document path after /docs/ is missing or malformed",
		})
		return
	}

	base := strings.TrimSpace(r.Header.Get("If-Match"))
	if base == "" {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "If-Match is required: send the approved-head revision you read at, so the " +
				"proposal has a base and a concurrent change cannot be clobbered",
		})
		return
	}

	proposalID, ok := parseProposalHeader(r.Header.Get("X-Proposal"))
	if !ok {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "X-Proposal must be a positive proposal id; omit it to open a new proposal",
		})
		return
	}

	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBodyBytes))
	if err != nil {
		// MaxBytesReader signals an over-limit body through a read error; there
		// is no way to tell it from a truncated client here, so both are 413.
		writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{
			"error": "the document body could not be read or exceeds the size limit",
		})
		return
	}

	// The acting agent is resolved from the bearer token by the middleware.
	// service.Propose refuses a non-agent, so an anonymous caller is a 403 there
	// rather than a check duplicated here.
	principal := authn.PrincipalFromContext(r.Context())

	res, err := s.writer.Propose(r.Context(), service.ProposeRequest{
		Space:      ref,
		Principal:  principal,
		ProposalID: proposalID,
		Title:      strings.TrimSpace(r.URL.Query().Get("title")),
		Rationale:  strings.TrimSpace(r.URL.Query().Get("rationale")),
		IfMatch:    base,
		Message:    strings.TrimSpace(r.URL.Query().Get("message")),
		Writes:     []service.DocumentWrite{{Path: docPath, Content: body}},
	})
	if err != nil {
		writeError(w, err)
		return
	}

	status := http.StatusOK
	if proposalID == 0 {
		status = http.StatusCreated
	}
	writeJSON(w, status, proposeResponse{
		Proposal: res.Proposal.ID,
		URL:      res.URL,
		Merged:   res.Merged,
		State:    string(res.Proposal.State),
		Branch:   res.Proposal.Branch,
		BaseRev:  res.Proposal.BaseRev,
	})
}

// parseProposalHeader reads the optional X-Proposal header. An empty header
// means "open a new proposal" and is valid; a present value must be a positive
// integer. It returns the id and whether the header was well-formed.
func parseProposalHeader(v string) (int, bool) {
	v = strings.TrimSpace(v)
	if v == "" {
		return 0, true
	}
	id, err := strconv.Atoi(v)
	if err != nil || id <= 0 {
		return 0, false
	}
	return id, true
}

// unescapePath decodes a chi trailing wildcard back into a document path,
// per segment.
//
// chi routes on the raw (percent-encoded) path when the request had one, so a
// document whose name carries a space or a Cyrillic letter arrives encoded.
// Decoding per segment is deliberate: a %2F inside a segment is a literal slash
// in a name, not a path separator, and joining decoded segments with "/" keeps
// it from becoming one. It mirrors web/'s unescapePath so the read and write
// surfaces address a document by exactly the same path grammar.
func unescapePath(raw string) (string, bool) {
	if raw == "" {
		return "", true
	}
	segs := strings.Split(raw, "/")
	for i, seg := range segs {
		dec, err := url.PathUnescape(seg)
		if err != nil {
			return "", false
		}
		segs[i] = dec
	}
	return strings.Join(segs, "/"), true
}

// compile-time assertion that the production service satisfies the write this
// surface needs.
var _ Writer = (*service.Service)(nil)