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)