142 lines
5.4 KiB
Go
142 lines
5.4 KiB
Go
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
|
|
}
|