package httpapi import ( "bytes" "compress/gzip" "crypto/sha256" "encoding/base64" "encoding/json" "net/http" "strconv" "strings" ) // conditional.go: how a JSON answer is written — the validator, the conditional read and the // compression, in one place and for one reason. // // Both are declared by the contract as things a client OBSERVES and may rely on: every collection // read and the book card answer an `ETag` and honour `If-None-Match` with `304`, and a server MUST // honour `Accept-Encoding` on `application/json` and MUST NOT compress `text/event-stream`. // // Here rather than in a middleware, which is what makes the last rule structural instead of // remembered: the body is already buffered to hash it, so compressing costs nothing extra, and the // stream never passes through this function at all. A wrapping middleware would carry the exemption // as a list of paths — the kind of list that goes stale in one pack. // gzipFloor is the size below which compressing costs more than it saves — a few packets either way, // plus a Content-Encoding a proxy then has to think about. const gzipFloor = 1 << 10 // writeJSON renders a response body, with the validator and the negotiation the contract requires. // // It returns the body as it was encoded — before any compression — because one caller needs exactly // that: an idempotent write stores what it answered so a retry can be given the same bytes. func (h *v0) writeJSON(w http.ResponseWriter, r *http.Request, status int, body any) []byte { buf, err := json.Marshal(body) if err != nil { h.log.ErrorContext(r.Context(), "response body could not be encoded", "err", err) Fail(w, r, CodeInternalError) return nil } plain := buf head := w.Header() head.Set("Content-Type", "application/json") // Declared on every negotiable representation, compressed or not: a cache that stored the plain // answer under a key that ignores the encoding would serve it to a client that asked for gzip. head.Add("Vary", "Accept-Encoding") // Only a safe read is conditional. A validator on the answer to a write would invite a client to // treat two different writes as one because their receipts happened to be identical. // // HEAD is included because the router gives it the same handler — a `GET` pattern matches HEAD // since Go 1.22 — and a HEAD that answered no validator would make a client's cheap freshness // check the one request that can never be answered `304`. if r.Method == http.MethodGet || r.Method == http.MethodHead { etag := entityTag(buf) head.Set("ETag", etag) if matches(r.Header.Get("If-None-Match"), etag) { // No body, and the ETag stays valid. RFC 9110 §15.4.5: a 304 carries the headers that // would have qualified the response, which is why the tag is set before this branch. head.Del("Content-Type") w.WriteHeader(http.StatusNotModified) return plain } } if acceptsGzip(r) && len(buf) >= gzipFloor { var out bytes.Buffer zw := gzip.NewWriter(&out) if _, err := zw.Write(buf); err == nil && zw.Close() == nil { buf = out.Bytes() head.Set("Content-Encoding", "gzip") } // A compressor that failed mid-way leaves the plain body, which is always a legal answer. } head.Set("Content-Length", strconv.Itoa(len(buf))) w.WriteHeader(status) if _, err := w.Write(buf); err != nil { h.log.DebugContext(r.Context(), "response body not delivered", "err", err) // the client went away } return plain } // entityTag is the validator of a representation. // // It is a hash of the BYTES, which is what makes it bound to the whole request rather than to a // revision: page two of a collection and a delta read of it are different bytes at the same // revision, and the contract requires their validators to differ. Weak, because the contract allows // a weak one and because two byte-identical bodies are equivalent for every use a client has. func entityTag(body []byte) string { sum := sha256.Sum256(body) return `W/"` + base64.RawURLEncoding.EncodeToString(sum[:12]) + `"` } // matches implements If-None-Match by weak comparison (RFC 9110 §13.1.2), including the `*` form. func matches(header, etag string) bool { header = strings.TrimSpace(header) if header == "" { return false } if header == "*" { return true } want := strings.TrimPrefix(etag, "W/") for _, candidate := range strings.Split(header, ",") { if strings.TrimPrefix(strings.TrimSpace(candidate), "W/") == want { return true } } return false } // acceptsGzip answers whether gzip may be used, over ALL Accept-Encoding lines of the request // (a proxy may add its own). The named coding decides; the wildcard only when gzip is unnamed; a // zero weight on either is a refusal (RFC 9110 §12.5.3). func acceptsGzip(r *http.Request) bool { named, wildcard := -1.0, -1.0 for _, part := range strings.Split(strings.Join(r.Header.Values("Accept-Encoding"), ","), ",") { name, params, _ := strings.Cut(strings.TrimSpace(part), ";") name = strings.TrimSpace(name) isGzip := strings.EqualFold(name, "gzip") if !isGzip && name != "*" { continue } weight := 1.0 for _, p := range strings.Split(params, ";") { key, value, ok := strings.Cut(strings.TrimSpace(p), "=") if !ok || !strings.EqualFold(strings.TrimSpace(key), "q") { continue } if w, err := strconv.ParseFloat(strings.TrimSpace(value), 64); err == nil { weight = w } } if isGzip { named = weight } else { wildcard = weight } } if named >= 0 { return named > 0 } return wildcard > 0 }