mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
App, Group, Operation. A group is a prefix and what holds under it, an operation
is one typed route declared there, and the verb methods are generic methods —
which Go 1.27 allows on a concrete type and still refuses on an interface. So In
and Out come from the handler and the caller writes no type arguments:
users := app.Group("/v1/users")
users.Post("/", api.Create)
mint := users.Group("/mint").With(middleware.Mint(gate))
mint.Post("/deposit", api.Deposit)
A Group is a CLOSED HANDLE: two unexported fields, and no method that hands back
the app, the parent or the fiber underneath. A subsystem given the group at its
own prefix cannot reach above it, so confinement is a property of the type
instead of a rule something has to police — which is what a host needs in order
to hand out a bounded surface and know what comes back.
That is bought by giving up the router interface, deliberately. Router and
OpTarget are gone, and with them the ability to implement a route table outside
this package: a decorator is no longer a router, a gate is middleware on a group,
and a bound is a path. The five package-level verb functions go the same way —
a registration is a method on the thing it registers into.
Raw carries what is not a typed operation: a stream, an upgrade, a probe, a
foreign subtree. It names its method, and it is the longer spelling because
opting out of the typed model is the exception and should read like one.
Co-authored-by: Hanzo Dev <[email protected]>
171 lines
6.2 KiB
Go
171 lines
6.2 KiB
Go
package zip_test
|
|
|
|
import (
|
|
"io/fs"
|
|
"net/http"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
// These tests pin zip.Static's byte-range answers (RFC 9110 §14). The property
|
|
// that matters is the three-way split: a range it HONOURS is 206 with the bytes
|
|
// asked for, a range it DECLINES is 200 with the whole file — always legal, and
|
|
// what a multi-range request gets — and a range that names bytes the file does
|
|
// not have is 416, which is a refusal and must not be answered with the file.
|
|
//
|
|
// Both fixtures run through bothFS, so whatever embed.FS and os.DirFS disagree
|
|
// about shows up here rather than in production.
|
|
|
|
func rangeApp(assets fs.FS) *zip.App {
|
|
app := zip.New(zip.Config{DisableStartupMessage: true})
|
|
app.Raw("GET", "/assets/*", zip.Static(assets))
|
|
return app
|
|
}
|
|
|
|
func TestStatic_RangeServesExactlyTheBytesAsked(t *testing.T) {
|
|
bothFS(t, func(t *testing.T, assets fs.FS) {
|
|
want, _ := fs.ReadFile(assets, "main.css")
|
|
size := len(want)
|
|
|
|
for _, tc := range []struct {
|
|
name, spec string
|
|
from, to int // inclusive, resolved against size
|
|
}{
|
|
{"prefix", "bytes=0-3", 0, 3},
|
|
{"middle", "bytes=4-8", 4, 8},
|
|
{"open ended", "bytes=5-", 5, size - 1},
|
|
{"suffix", "bytes=-4", size - 4, size - 1},
|
|
{"past the end clamps", "bytes=10-9999", 10, size - 1},
|
|
{"whole file by span", "bytes=0-" + strconv.Itoa(size-1), 0, size - 1},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
resp, body := serve(t, rangeApp(assets), "GET", "/assets/main.css",
|
|
map[string]string{"Range": tc.spec})
|
|
if resp.StatusCode != http.StatusPartialContent {
|
|
t.Fatalf("status %d, want 206", resp.StatusCode)
|
|
}
|
|
if got, w := body, string(want[tc.from:tc.to+1]); got != w {
|
|
t.Fatalf("body %q, want %q", got, w)
|
|
}
|
|
wantCR := "bytes " + strconv.Itoa(tc.from) + "-" + strconv.Itoa(tc.to) + "/" + strconv.Itoa(size)
|
|
if got := resp.Header.Get("Content-Range"); got != wantCR {
|
|
t.Fatalf("Content-Range %q, want %q", got, wantCR)
|
|
}
|
|
if got, w := resp.Header.Get("Content-Length"), strconv.Itoa(tc.to-tc.from+1); got != w {
|
|
t.Fatalf("Content-Length %q, want %q", got, w)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestStatic_AcceptRangesIsAdvertised(t *testing.T) {
|
|
bothFS(t, func(t *testing.T, assets fs.FS) {
|
|
resp, _ := serve(t, rangeApp(assets), "GET", "/assets/main.css", nil)
|
|
if got := resp.Header.Get("Accept-Ranges"); got != "bytes" {
|
|
t.Fatalf("Accept-Ranges %q, want %q — a client cannot ask for a range it is not told about", got, "bytes")
|
|
}
|
|
})
|
|
}
|
|
|
|
// A span wholly outside the file is a REFUSAL. Answering it with the file would
|
|
// hand a client bytes it did not ask for and call that success.
|
|
func TestStatic_UnsatisfiableRangeIsRefusedNotServed(t *testing.T) {
|
|
bothFS(t, func(t *testing.T, assets fs.FS) {
|
|
want, _ := fs.ReadFile(assets, "main.css")
|
|
size := len(want)
|
|
for _, spec := range []string{"bytes=" + strconv.Itoa(size) + "-", "bytes=9999-", "bytes=-0"} {
|
|
resp, body := serve(t, rangeApp(assets), "GET", "/assets/main.css",
|
|
map[string]string{"Range": spec})
|
|
if resp.StatusCode != http.StatusRequestedRangeNotSatisfiable {
|
|
t.Fatalf("%s: status %d, want 416", spec, resp.StatusCode)
|
|
}
|
|
if body != "" {
|
|
t.Fatalf("%s: body %q, want empty", spec, body)
|
|
}
|
|
if got, w := resp.Header.Get("Content-Range"), "bytes */"+strconv.Itoa(size); got != w {
|
|
t.Fatalf("%s: Content-Range %q, want %q", spec, got, w)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// Declining a range is always legal, so these answer the whole file rather than
|
|
// failing. Multi-range is the case a client actually sends.
|
|
func TestStatic_DeclinedRangeServesTheWholeFile(t *testing.T) {
|
|
bothFS(t, func(t *testing.T, assets fs.FS) {
|
|
want, _ := fs.ReadFile(assets, "main.css")
|
|
for _, spec := range []string{"bytes=0-1,4-5", "items=0-3", "bytes=abc-def", "bytes=", "not a range"} {
|
|
resp, body := serve(t, rangeApp(assets), "GET", "/assets/main.css",
|
|
map[string]string{"Range": spec})
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("%s: status %d, want 200", spec, resp.StatusCode)
|
|
}
|
|
if body != string(want) {
|
|
t.Fatalf("%s: body %q, want the whole file %q", spec, body, want)
|
|
}
|
|
if cr := resp.Header.Get("Content-Range"); cr != "" {
|
|
t.Fatalf("%s: Content-Range %q on a 200, want none", spec, cr)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// If-Range is the guard against splicing two versions of a file together: the
|
|
// range is honoured only when the representation has not moved.
|
|
func TestStatic_IfRangeDecidesWhetherTheRangeApplies(t *testing.T) {
|
|
bothFS(t, func(t *testing.T, assets fs.FS) {
|
|
want, _ := fs.ReadFile(assets, "main.css")
|
|
app := rangeApp(assets)
|
|
|
|
full, _ := serve(t, app, "GET", "/assets/main.css", nil)
|
|
mod := full.Header.Get("Last-Modified")
|
|
if mod == "" {
|
|
t.Skip("no Last-Modified on this fs, so If-Range has nothing to match")
|
|
}
|
|
|
|
resp, body := serve(t, app, "GET", "/assets/main.css",
|
|
map[string]string{"Range": "bytes=0-3", "If-Range": mod})
|
|
if resp.StatusCode != http.StatusPartialContent {
|
|
t.Fatalf("matching If-Range: status %d, want 206", resp.StatusCode)
|
|
}
|
|
if body != string(want[:4]) {
|
|
t.Fatalf("matching If-Range: body %q, want %q", body, want[:4])
|
|
}
|
|
|
|
stale := time.Now().Add(-72 * time.Hour).UTC().Format(http.TimeFormat)
|
|
resp, body = serve(t, app, "GET", "/assets/main.css",
|
|
map[string]string{"Range": "bytes=0-3", "If-Range": stale})
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("stale If-Range: status %d, want 200 (whole file)", resp.StatusCode)
|
|
}
|
|
if body != string(want) {
|
|
t.Fatalf("stale If-Range: body %q, want the whole file", body)
|
|
}
|
|
})
|
|
}
|
|
|
|
// A 304 outranks a range: the client already holds the representation, so there
|
|
// is nothing to send a piece of.
|
|
func TestStatic_NotModifiedOutranksARange(t *testing.T) {
|
|
bothFS(t, func(t *testing.T, assets fs.FS) {
|
|
app := rangeApp(assets)
|
|
full, _ := serve(t, app, "GET", "/assets/main.css", nil)
|
|
mod := full.Header.Get("Last-Modified")
|
|
if mod == "" {
|
|
t.Skip("no Last-Modified on this fs")
|
|
}
|
|
resp, body := serve(t, app, "GET", "/assets/main.css",
|
|
map[string]string{"Range": "bytes=0-3", "If-Modified-Since": mod})
|
|
if resp.StatusCode != http.StatusNotModified {
|
|
t.Fatalf("status %d, want 304", resp.StatusCode)
|
|
}
|
|
if body != "" {
|
|
t.Fatalf("body %q on a 304, want empty", body)
|
|
}
|
|
})
|
|
}
|