From faac20c2b423cb25b57a159c739dbe73bbbb5ff5 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:05:15 -0400 Subject: [PATCH 1/3] feat(rest): compress the docs surface via the existing gzip middleware The docs handler served the embedded Scalar bundle (~3.6 MB) and the OpenAPI spec uncompressed, so they shipped full size on first paint even though /api responses already negotiated gzip. This wraps the docs handler in the existing GzipMiddleware, so the spec and the static assets compress when a client sends Accept-Encoding: gzip and serve verbatim when it does not. The handler owns its own compression, which keeps the public-listener split a plain path dispatch and leaves the /api chain alone. rest/docs_gzip_test.go covers both modes: negotiated (Content-Encoding: gzip, decodes back to the verbatim bytes, spec version stamp intact) and not negotiated (served raw). The existing docs tests send no Accept-Encoding and keep passing unchanged. > *This was generated by AI* --- rest/docs.go | 13 ++++- rest/docs_gzip_test.go | 118 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 rest/docs_gzip_test.go diff --git a/rest/docs.go b/rest/docs.go index 8a39fd8..2728ede 100644 --- a/rest/docs.go +++ b/rest/docs.go @@ -67,12 +67,21 @@ func DocsHandler(version string) http.Handler { []byte("version: "+version), 1) } - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // GzipMiddleware compresses both the spec and the static assets (the ~3.6 MB + // Scalar bundle is the one that matters) when the client advertises gzip, the + // same negotiation the /api stack runs. The docs handler owns its own + // compression so the public-listener split stays a plain path dispatch and + // only the docs surface changes. The middleware already handles the + // file-server shapes this surface produces — the stale uncompressed + // Content-Length http.FileServer sets, HEAD, and 304 conditional GETs — and a + // client that does not negotiate gzip is served the bytes verbatim, exactly + // as before. + return GzipMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == specURLPath { w.Header().Set("Content-Type", "application/yaml") _, _ = w.Write(spec) return } fileServer.ServeHTTP(w, r) - }) + })) } diff --git a/rest/docs_gzip_test.go b/rest/docs_gzip_test.go new file mode 100644 index 0000000..7c9ca02 --- /dev/null +++ b/rest/docs_gzip_test.go @@ -0,0 +1,118 @@ +package rest_test + +// Behavioral tests for the docs surface's gzip negotiation (#218). The docs +// handler serves the embedded Scalar bundle (~3.6 MB) and the OpenAPI spec; it +// must compress them when the client advertises Accept-Encoding: gzip and serve +// them verbatim when it does not — reusing the same GzipMiddleware the /api +// stack uses, not a second compression path. A ResponseRecorder is enough here: +// the deferred gz.Close runs before ServeHTTP returns, so the recorder body +// holds the complete gzip stream, and decode-equality against the verbatim +// response is the acceptance check (the middleware's stale-Content-Length wire +// behavior is pinned crisply in gzip_test.go). + +import ( + "bytes" + "compress/gzip" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/7cav/api/rest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// docsGetGzip issues a docs request advertising Accept-Encoding: gzip. The +// recorder does not transparently decompress (only a real transport does), so +// the body is the raw response bytes exactly as the wire would carry them. +func docsGetGzip(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder { + t.Helper() + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("Accept-Encoding", "gzip") + h.ServeHTTP(rr, req) + return rr +} + +// gunzip decodes a gzip stream whole, asserting an intact trailer (CRC + size). +func gunzip(t *testing.T, b []byte) []byte { + t.Helper() + zr, err := gzip.NewReader(bytes.NewReader(b)) + require.NoError(t, err, "body must open as a gzip stream") + decoded, err := io.ReadAll(zr) + require.NoError(t, err, "compressed stream must arrive whole, not truncated") + require.NoError(t, zr.Close(), "gzip trailer (CRC + size) must be intact") + return decoded +} + +// A docs-asset request advertising gzip gets Content-Encoding: gzip and a body +// that decodes back to the exact bytes the verbatim (no-Accept-Encoding) +// response serves. The Scalar bundle is the large asset this issue is about. +func TestDocsHandler_GzipsBundleWhenNegotiated(t *testing.T) { + h := rest.DocsHandler("dev") + + plain := docsGet(t, h, "/scalar.standalone.js") + require.Equal(t, http.StatusOK, plain.Code) + require.Empty(t, plain.Header().Get("Content-Encoding"), + "the verbatim request advertises no encoding, so the response carries none") + + gz := docsGetGzip(t, h, "/scalar.standalone.js") + require.Equal(t, http.StatusOK, gz.Code) + require.Equal(t, "gzip", gz.Header().Get("Content-Encoding"), + "a gzip-negotiated docs asset must be served compressed") + + assert.Less(t, gz.Body.Len(), plain.Body.Len(), + "the compressed bundle must be smaller than the verbatim bundle") + assert.Equal(t, plain.Body.Bytes(), gunzip(t, gz.Body.Bytes()), + "the compressed bundle must decode byte-for-byte to the verbatim asset") +} + +// The served OpenAPI spec compresses on the same terms, and the build-time +// info.version stamping survives the compression: a gzip-negotiated spec +// request decodes to exactly the stamped verbatim spec, dev sentinel gone. +func TestDocsHandler_GzipsSpecWithVersionStampIntact(t *testing.T) { + h := rest.DocsHandler("v2.5.0") + + plain := docsGet(t, h, "/openapi.yaml") + require.Equal(t, http.StatusOK, plain.Code) + require.Empty(t, plain.Header().Get("Content-Encoding")) + + gz := docsGetGzip(t, h, "/openapi.yaml") + require.Equal(t, http.StatusOK, gz.Code) + require.Equal(t, "gzip", gz.Header().Get("Content-Encoding"), + "the served spec must compress when gzip is negotiated") + + decoded := gunzip(t, gz.Body.Bytes()) + assert.Equal(t, plain.Body.Bytes(), decoded, + "the compressed spec must decode byte-for-byte to the verbatim spec") + assert.Contains(t, string(decoded), "version: v2.5.0", + "the build-time version stamp must survive compression") + assert.NotContains(t, string(decoded), "version: dev", + "the dev sentinel must stay replaced under compression") +} + +// A docs request that does not advertise gzip is served verbatim — no +// Content-Encoding, byte-for-byte as before #218. This is the half of the +// negotiation the existing docs tests already exercise implicitly (they send no +// Accept-Encoding); pin it explicitly for both the asset and the spec path so a +// regression that compresses unconditionally is caught. +func TestDocsHandler_ServesVerbatimWhenGzipNotNegotiated(t *testing.T) { + h := rest.DocsHandler("dev") + + for _, path := range []string{"/scalar.standalone.js", "/openapi.yaml", "/"} { + t.Run(path, func(t *testing.T) { + rr := docsGet(t, h, path) + require.Equal(t, http.StatusOK, rr.Code) + assert.Empty(t, rr.Header().Get("Content-Encoding"), + "an un-negotiated docs response must not be compressed") + // The body must not be a gzip stream — its first bytes must not be + // the gzip magic number (0x1f 0x8b). + if rr.Body.Len() >= 2 { + b := rr.Body.Bytes() + assert.False(t, b[0] == 0x1f && b[1] == 0x8b, + "an un-negotiated body must be raw, not a gzip stream") + } + }) + } +} From 094f4d501daec9e1375a8cdde8faa2dc87ebe720 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:15:24 -0400 Subject: [PATCH 2/3] test(rest): pin docs gzip Content-Length on the wire Add a real-wire test over httptest.NewServer that fetches the Scalar bundle with Accept-Encoding: gzip set by hand, so the transport leaves the raw gzip stream alone. It proves the stale uncompressed Content-Length that http.FileServer sets never reaches a length-enforcing transport, which is acceptance criterion 4. The recorder-based tests can't observe this, since a recorder enforces no Content-Length, so also pin an empty Content-Length on those as a cheap regression catch. Fix the docs.go wrap comment, which claimed this surface produces 304 conditional GETs. The embedded FS has no modtime or ETag, so http.FileServer sends no validators and never answers a 304 here. Replace the body-length guard in the verbatim test so a short or empty body fails loudly instead of skipping the magic-byte check. Reword two test comments that described the change instead of the behavior. > *This was generated by AI* --- rest/docs.go | 12 +++--- rest/docs_gzip_test.go | 87 +++++++++++++++++++++++++++++++++++------- 2 files changed, 81 insertions(+), 18 deletions(-) diff --git a/rest/docs.go b/rest/docs.go index 2728ede..88af26b 100644 --- a/rest/docs.go +++ b/rest/docs.go @@ -71,11 +71,13 @@ func DocsHandler(version string) http.Handler { // Scalar bundle is the one that matters) when the client advertises gzip, the // same negotiation the /api stack runs. The docs handler owns its own // compression so the public-listener split stays a plain path dispatch and - // only the docs surface changes. The middleware already handles the - // file-server shapes this surface produces — the stale uncompressed - // Content-Length http.FileServer sets, HEAD, and 304 conditional GETs — and a - // client that does not negotiate gzip is served the bytes verbatim, exactly - // as before. + // only the docs surface changes. The file-server shapes this surface actually + // produces — the stale uncompressed Content-Length http.FileServer sets, and + // HEAD — the middleware already handles. (It is also hardened against the + // bodyless 304/204 shapes, but this surface never mints them: the embedded FS + // carries no modtime or ETag, so http.FileServer sends no validators and + // answers no conditional GET with a 304.) A client that does not negotiate + // gzip is served the bytes verbatim. return GzipMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == specURLPath { w.Header().Set("Content-Type", "application/yaml") diff --git a/rest/docs_gzip_test.go b/rest/docs_gzip_test.go index 7c9ca02..977fdab 100644 --- a/rest/docs_gzip_test.go +++ b/rest/docs_gzip_test.go @@ -23,9 +23,11 @@ import ( "github.com/stretchr/testify/require" ) -// docsGetGzip issues a docs request advertising Accept-Encoding: gzip. The +// docsGetGzip issues a docs request advertising Accept-Encoding: gzip. A // recorder does not transparently decompress (only a real transport does), so -// the body is the raw response bytes exactly as the wire would carry them. +// the body holds the gzip stream directly for the test to decode. It is not a +// faithful wire image, though: a recorder enforces no Content-Length, so the +// genuine-transport check lives in the httptest.NewServer test below. func docsGetGzip(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder { t.Helper() rr := httptest.NewRecorder() @@ -61,6 +63,8 @@ func TestDocsHandler_GzipsBundleWhenNegotiated(t *testing.T) { require.Equal(t, http.StatusOK, gz.Code) require.Equal(t, "gzip", gz.Header().Get("Content-Encoding"), "a gzip-negotiated docs asset must be served compressed") + require.Empty(t, gz.Header().Get("Content-Length"), + "the stale uncompressed Content-Length http.FileServer sets must be stripped on first write") assert.Less(t, gz.Body.Len(), plain.Body.Len(), "the compressed bundle must be smaller than the verbatim bundle") @@ -82,6 +86,8 @@ func TestDocsHandler_GzipsSpecWithVersionStampIntact(t *testing.T) { require.Equal(t, http.StatusOK, gz.Code) require.Equal(t, "gzip", gz.Header().Get("Content-Encoding"), "the served spec must compress when gzip is negotiated") + require.Empty(t, gz.Header().Get("Content-Length"), + "the compressed spec must carry no stale uncompressed Content-Length") decoded := gunzip(t, gz.Body.Bytes()) assert.Equal(t, plain.Body.Bytes(), decoded, @@ -92,11 +98,12 @@ func TestDocsHandler_GzipsSpecWithVersionStampIntact(t *testing.T) { "the dev sentinel must stay replaced under compression") } -// A docs request that does not advertise gzip is served verbatim — no -// Content-Encoding, byte-for-byte as before #218. This is the half of the -// negotiation the existing docs tests already exercise implicitly (they send no -// Accept-Encoding); pin it explicitly for both the asset and the spec path so a -// regression that compresses unconditionally is caught. +// A docs request that does not advertise gzip is served verbatim: no +// Content-Encoding, and the body is the raw asset bytes, not a gzip stream. +// This is the half of the negotiation the existing docs tests already exercise +// implicitly (they send no Accept-Encoding); pin it explicitly for both the +// asset and the spec path so a regression that compresses unconditionally is +// caught. func TestDocsHandler_ServesVerbatimWhenGzipNotNegotiated(t *testing.T) { h := rest.DocsHandler("dev") @@ -107,12 +114,66 @@ func TestDocsHandler_ServesVerbatimWhenGzipNotNegotiated(t *testing.T) { assert.Empty(t, rr.Header().Get("Content-Encoding"), "an un-negotiated docs response must not be compressed") // The body must not be a gzip stream — its first bytes must not be - // the gzip magic number (0x1f 0x8b). - if rr.Body.Len() >= 2 { - b := rr.Body.Bytes() - assert.False(t, b[0] == 0x1f && b[1] == 0x8b, - "an un-negotiated body must be raw, not a gzip stream") - } + // the gzip magic number (0x1f 0x8b). Assert the body is non-trivial + // first, so a short/empty response fails loudly instead of skipping + // the magic-byte check. + require.GreaterOrEqual(t, rr.Body.Len(), 2, + "the docs body must be non-trivial, not empty or truncated") + b := rr.Body.Bytes() + assert.False(t, b[0] == 0x1f && b[1] == 0x8b, + "an un-negotiated body must be raw, not a gzip stream") }) } } + +// The genuine wire check for acceptance criterion 4 (#218): over a real +// transport — which, unlike a recorder, enforces a declared Content-Length — a +// gzip-negotiated bundle request must carry no stale uncompressed +// Content-Length. http.FileServer sets Content-Length to the UNCOMPRESSED size; +// were GzipMiddleware to leave it on the response, net/http would close the +// connection short of the declared length and the client would hit unexpected +// EOF, so the gunzip below would fail instead of decoding the asset whole. This +// is the only construction that exercises the real GzipMiddleware+http.FileServer +// composition on a length-enforcing transport. Setting Accept-Encoding: gzip by +// hand suppresses the transport's transparent decompression, so the test reads +// the raw gzip stream with Content-Encoding intact; the verbatim asset is +// fetched over the same server WITHOUT the header, where the transport +// transparently yields the decoded bytes. +func TestDocsHandler_GzipBundleCarriesNoStaleContentLengthOnTheWire(t *testing.T) { + srv := httptest.NewServer(rest.DocsHandler("dev")) + defer srv.Close() + + // Compressed fetch: Accept-Encoding set by hand, so the transport leaves the + // gzip stream raw and preserves Content-Encoding. + gzReq, err := http.NewRequest(http.MethodGet, srv.URL+"/scalar.standalone.js", nil) + require.NoError(t, err) + gzReq.Header.Set("Accept-Encoding", "gzip") + gzRes, err := srv.Client().Do(gzReq) + require.NoError(t, err) + defer gzRes.Body.Close() + + require.Equal(t, http.StatusOK, gzRes.StatusCode) + require.Equal(t, "gzip", gzRes.Header.Get("Content-Encoding"), + "a gzip-negotiated bundle must be served compressed") + require.Empty(t, gzRes.Header.Get("Content-Length"), + "the stale uncompressed Content-Length must never reach the wire") + + zr, err := gzip.NewReader(gzRes.Body) + require.NoError(t, err, "body must open as a gzip stream") + decoded, err := io.ReadAll(zr) + require.NoError(t, err, + "compressed bundle must arrive whole, not truncated at a stale Content-Length") + require.NoError(t, zr.Close(), "gzip trailer (CRC + size) must be intact") + + // Verbatim fetch over the same server: no Accept-Encoding, so the transport + // negotiates gzip and transparently decodes, yielding the raw asset bytes. + verbatimRes, err := srv.Client().Get(srv.URL + "/scalar.standalone.js") + require.NoError(t, err) + defer verbatimRes.Body.Close() + require.Equal(t, http.StatusOK, verbatimRes.StatusCode) + verbatim, err := io.ReadAll(verbatimRes.Body) + require.NoError(t, err) + + assert.Equal(t, verbatim, decoded, + "the compressed bundle must decode byte-for-byte to the verbatim asset") +} From 3cc1111b580e51971acaaa5a88dc0feb9d1fbb1e Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:21:41 -0400 Subject: [PATCH 3/3] docs(rest): correct docs gzip test comments to match the wire test The package-level doc still said a ResponseRecorder was enough for the whole file and that the stale-Content-Length wire behavior lived in gzip_test.go. Neither holds anymore: this file now has a httptest.NewServer test that pins that behavior on a length-enforcing transport, which a recorder can't observe. Rewrote the doc to say what each test actually covers. Also reworded the wire test's comment and one error message so the direct Content-Length header assertion reads as the primary guard for criterion 4, with the gunzip round-trip framed as byte-for-byte integrity verification rather than the main truncation catch. Comments and strings only, no behavioral change. > *This was generated by AI* --- rest/docs_gzip_test.go | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/rest/docs_gzip_test.go b/rest/docs_gzip_test.go index 977fdab..3407d01 100644 --- a/rest/docs_gzip_test.go +++ b/rest/docs_gzip_test.go @@ -4,11 +4,14 @@ package rest_test // handler serves the embedded Scalar bundle (~3.6 MB) and the OpenAPI spec; it // must compress them when the client advertises Accept-Encoding: gzip and serve // them verbatim when it does not — reusing the same GzipMiddleware the /api -// stack uses, not a second compression path. A ResponseRecorder is enough here: -// the deferred gz.Close runs before ServeHTTP returns, so the recorder body -// holds the complete gzip stream, and decode-equality against the verbatim -// response is the acceptance check (the middleware's stale-Content-Length wire -// behavior is pinned crisply in gzip_test.go). +// stack uses, not a second compression path. The recorder-based tests below +// cover gzip negotiation and decode-equality against the verbatim response (with +// a Content-Length header pin); the deferred gz.Close runs before ServeHTTP +// returns, so the recorder body holds the complete gzip stream. A recorder +// cannot observe the wire Content-Length, though, so the docs surface's +// stale-Content-Length behavior is pinned by the httptest.NewServer test in this +// file, on a length-enforcing transport; gzip_test.go covers the generic +// GzipMiddleware composition. import ( "bytes" @@ -129,16 +132,20 @@ func TestDocsHandler_ServesVerbatimWhenGzipNotNegotiated(t *testing.T) { // The genuine wire check for acceptance criterion 4 (#218): over a real // transport — which, unlike a recorder, enforces a declared Content-Length — a // gzip-negotiated bundle request must carry no stale uncompressed -// Content-Length. http.FileServer sets Content-Length to the UNCOMPRESSED size; -// were GzipMiddleware to leave it on the response, net/http would close the -// connection short of the declared length and the client would hit unexpected -// EOF, so the gunzip below would fail instead of decoding the asset whole. This -// is the only construction that exercises the real GzipMiddleware+http.FileServer -// composition on a length-enforcing transport. Setting Accept-Encoding: gzip by -// hand suppresses the transport's transparent decompression, so the test reads -// the raw gzip stream with Content-Encoding intact; the verbatim asset is -// fetched over the same server WITHOUT the header, where the transport -// transparently yields the decoded bytes. +// Content-Length. The deterministic guard is the direct Content-Length header +// assertion below (require.Empty): http.FileServer sets Content-Length to the +// UNCOMPRESSED size, and this test fails the moment that stale value reaches the +// wire. The gunzip round-trip that follows is additional integrity +// verification — it confirms the asset decodes byte-for-byte — and only +// contingently doubles as a truncation catch: were the stale length left on, +// net/http would also close the connection short of the declared length and the +// client would hit unexpected EOF. This is the only construction that exercises +// the real GzipMiddleware+http.FileServer composition on a length-enforcing +// transport. Setting Accept-Encoding: gzip by hand suppresses the transport's +// transparent decompression, so the test reads the raw gzip stream with +// Content-Encoding intact; the verbatim asset is fetched over the same server +// WITHOUT the header, where the transport transparently yields the decoded +// bytes. func TestDocsHandler_GzipBundleCarriesNoStaleContentLengthOnTheWire(t *testing.T) { srv := httptest.NewServer(rest.DocsHandler("dev")) defer srv.Close() @@ -162,7 +169,7 @@ func TestDocsHandler_GzipBundleCarriesNoStaleContentLengthOnTheWire(t *testing.T require.NoError(t, err, "body must open as a gzip stream") decoded, err := io.ReadAll(zr) require.NoError(t, err, - "compressed bundle must arrive whole, not truncated at a stale Content-Length") + "compressed bundle must decode whole (byte-for-byte integrity check; the Content-Length header pin above is the primary stale-length guard)") require.NoError(t, zr.Close(), "gzip trailer (CRC + size) must be intact") // Verbatim fetch over the same server: no Accept-Encoding, so the transport