Files
zip/pathparam_test.go
zeekayandHanzo Dev 5f71d2496b The router is three nouns, and every one of them is concrete
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]>
2026-09-17 01:39:17 -07:00

300 lines
10 KiB
Go

package zip_test
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"github.com/zap-proto/zip"
)
// memberIn is a member-route input: identity in the path, payload in the body.
type memberIn struct {
Owner string `json:"owner"`
Name string `json:"name"`
Note string `json:"note"`
}
type memberOut struct {
Owner string `json:"owner"`
Name string `json:"name"`
Note string `json:"note"`
}
func echoMember(_ context.Context, in *memberIn) (*memberOut, error) {
return &memberOut{Owner: in.Owner, Name: in.Name, Note: in.Note}, nil
}
func memberApp(t *testing.T) *zip.App {
t.Helper()
a := zip.New(zip.Config{AppName: "t", DisableStartupMessage: true})
a.Get("/v1/t/things/:owner/:name", echoMember)
a.Patch("/v1/t/things/:owner/:name", echoMember)
return a
}
func do(t *testing.T, a *zip.App, method, path, body string) (int, memberOut) {
t.Helper()
var r io.Reader
if body != "" {
r = strings.NewReader(body)
}
req, err := http.NewRequest(method, path, r)
if err != nil {
t.Fatal(err)
}
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := a.Fiber().Test(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var out memberOut
raw, _ := io.ReadAll(resp.Body)
_ = json.Unmarshal(raw, &out)
return resp.StatusCode, out
}
// A GET member route has no body at all, so the path is the ONLY source of the
// target. Before path binding this decoded to the zero value and the handler
// could not tell which resource was asked for.
func TestPathParamsBindOnGet(t *testing.T) {
code, out := do(t, memberApp(t), "GET", "/v1/t/things/acme/widget", "")
if code != 200 {
t.Fatalf("status = %d, want 200", code)
}
if out.Owner != "acme" || out.Name != "widget" {
t.Fatalf("got (%q,%q), want (acme,widget)", out.Owner, out.Name)
}
}
// The body still fills every field the path does not name.
func TestBodyFillsNonPathFields(t *testing.T) {
code, out := do(t, memberApp(t), "PATCH", "/v1/t/things/acme/widget", `{"note":"hello"}`)
if code != 200 {
t.Fatalf("status = %d, want 200", code)
}
if out.Note != "hello" {
t.Fatalf("note = %q, want hello", out.Note)
}
if out.Owner != "acme" || out.Name != "widget" {
t.Fatalf("got (%q,%q), want (acme,widget)", out.Owner, out.Name)
}
}
// THE SECURITY PROPERTY: a body that names a DIFFERENT target than the URL must
// not win. The URL routed the request and the authorizer runs on this same
// decoded value, so if the body could override the path the authorizer would
// approve one resource while the handler wrote another.
func TestPathBeatsBodyOnConflict(t *testing.T) {
code, out := do(t, memberApp(t), "PATCH", "/v1/t/things/acme/widget",
`{"owner":"victim","name":"secret","note":"n"}`)
if code != 200 {
t.Fatalf("status = %d, want 200", code)
}
if out.Owner != "acme" || out.Name != "widget" {
t.Fatalf("body overrode the URL: got (%q,%q), want (acme,widget)", out.Owner, out.Name)
}
}
// A route with no params must behave exactly as before — the body is the whole
// input. This is what guarantees the change cannot disturb existing collection
// routes.
func TestNoParamsLeavesBodyIntact(t *testing.T) {
a := zip.New(zip.Config{AppName: "t", DisableStartupMessage: true})
a.Post("/v1/t/things", echoMember)
code, out := do(t, a, "POST", "/v1/t/things", `{"owner":"acme","name":"widget","note":"n"}`)
if code != 200 {
t.Fatalf("status = %d, want 200", code)
}
if out.Owner != "acme" || out.Name != "widget" || out.Note != "n" {
t.Fatalf("body binding regressed: %+v", out)
}
}
// The authorizer must see the PATH target, since it runs on the decoded input.
// This is the property IAM's write authorization depends on.
func TestAuthorizerSeesPathTarget(t *testing.T) {
a := zip.New(zip.Config{AppName: "t", DisableStartupMessage: true})
var sawOwner, sawName string
a.Authorize(func(_ context.Context, _ zip.Op, in any) (zip.Decision, error) {
if m, ok := in.(*memberIn); ok {
sawOwner, sawName = m.Owner, m.Name
}
return zip.Decision{Effect: zip.Allow}, nil
})
a.Patch("/v1/t/things/:owner/:name", echoMember)
if code, _ := do(t, a, "PATCH", "/v1/t/things/acme/widget", `{"owner":"victim"}`); code != 200 {
t.Fatalf("status = %d, want 200", code)
}
if sawOwner != "acme" || sawName != "widget" {
t.Fatalf("authorizer saw (%q,%q), want (acme,widget)", sawOwner, sawName)
}
}
// The generated spec must declare every templated segment, or it is invalid
// OpenAPI and every generator downstream rejects it.
func TestOpenAPIDeclaresPathParams(t *testing.T) {
a := memberApp(t)
if err := a.Build(); err != nil {
t.Fatalf("Build: %v", err)
}
req, _ := http.NewRequest("GET", "/.well-known/openapi.json", nil)
resp, err := a.Fiber().Test(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
var spec map[string]any
if err := json.Unmarshal(raw, &spec); err != nil {
t.Fatalf("spec is not json: %v", err)
}
paths, _ := spec["paths"].(map[string]any)
item, ok := paths["/v1/t/things/{owner}/{name}"].(map[string]any)
if !ok {
t.Fatalf("templated path missing from spec; got keys %v", keysOf(paths))
}
get, _ := item["get"].(map[string]any)
params, _ := get["parameters"].([]any)
// Path params first, in pattern order, required.
if len(params) < 2 {
t.Fatalf("parameters = %v, want the 2 path params declared", params)
}
for i, want := range []string{"owner", "name"} {
p, _ := params[i].(map[string]any)
if p["name"] != want || p["in"] != "path" || p["required"] != true {
t.Fatalf("parameter %d = %v, want required path param %q", i, p, want)
}
}
// Then the rest of the input, which a GET binds from the query string.
// `note` is not a path segment, so ?note= is the ONLY way to supply it —
// the document must say so or it describes a call nobody can make.
if len(params) != 3 {
t.Fatalf("parameters = %v, want 2 path + 1 query", params)
}
q, _ := params[2].(map[string]any)
if q["name"] != "note" || q["in"] != "query" || q["required"] != false {
t.Fatalf("parameter 2 = %v, want optional query param \"note\"", q)
}
// A field that IS a path segment is declared once, as a path param — never
// duplicated into the query set.
seen := map[string]int{}
for _, raw := range params {
p, _ := raw.(map[string]any)
seen[p["name"].(string)]++
}
for name, n := range seen {
if n != 1 {
t.Errorf("parameter %q declared %d times, want exactly 1", name, n)
}
}
}
// countIn addresses a NUMBERED resource: the path segment binds to an int
// field, and `page` rides the query string of the same request.
type countIn struct {
N int `json:"n"`
Page int32 `json:"page"`
}
func echoCount(_ context.Context, in *countIn) (*countIn, error) { return in, nil }
// A path parameter's type is the type of the field it binds to. Both halves of
// the URL run through one binder (bindURL), so describing one half from the
// input type and hardcoding the other to "string" made the document contradict
// itself about the same value — and every SDK generated from it took a string
// where the handler wanted a number.
func TestOpenAPITypesPathParamsFromTheInput(t *testing.T) {
a := zip.New(zip.Config{AppName: "t", DisableStartupMessage: true})
a.Get("/v1/t/counts/:n", echoCount)
// :missing names no field, so nothing types it and the wire carried text.
a.Get("/v1/t/other/:missing", echoCount)
params := paramsOf(t, a, "/v1/t/counts/{n}")
if len(params) != 2 {
t.Fatalf("parameters = %v, want the path param and the query param", params)
}
for _, p := range params {
decl, _ := p.(map[string]any)
schema, _ := decl["schema"].(map[string]any)
if schema["type"] != "integer" {
t.Errorf("%v %q schema = %v, want integer", decl["in"], decl["name"], schema)
}
}
missing, _ := paramsOf(t, a, "/v1/t/other/{missing}")[0].(map[string]any)
if s, _ := missing["schema"].(map[string]any); s["type"] != "string" {
t.Errorf("undeclared param schema = %v, want string", s)
}
}
func paramsOf(t *testing.T, a *zip.App, path string) []any {
t.Helper()
spec := a.OpenAPISpec()
paths, _ := spec["paths"].(map[string]map[string]any)
item, ok := paths[path]
if !ok {
t.Fatalf("no path %q in spec", path)
}
get, _ := item["get"].(map[string]any)
params, _ := get["parameters"].([]any)
return params
}
func keysOf(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
// A path parameter is percent-encoded on the wire — it has to be: a space, a
// slash or a percent has no other spelling between two slashes. The router
// matches on the raw text, so until this was decoded a handler addressed at
// /v1/t/things/acme/café was handed "caf%C3%A9" and looked up a name nobody
// has. Every client encodes, so before this no client could make such a value
// round-trip.
func TestPathParamsArriveDecoded(t *testing.T) {
for _, c := range []struct{ sent, want string }{
{"plain", "plain"},
{"a%20b", "a b"},
{"a%2Fb", "a/b"}, // a slash inside one segment has only this spelling
{"caf%C3%A9", "café"}, // and non-ASCII has only this one
{"100%25", "100%"},
{"a+b", "a+b"}, // a plus is a plus in a path; only a query reads it as a space
} {
code, out := do(t, memberApp(t), "GET", "/v1/t/things/acme/"+c.sent, "")
if code != 200 {
t.Errorf("%q: status = %d, want 200", c.sent, code)
continue
}
if out.Name != c.want {
t.Errorf("%q arrived as %q, want %q", c.sent, out.Name, c.want)
}
}
}
// The untyped accessor reads the same value the typed binder does. Two
// spellings of one parameter would be two answers to the same question.
func TestParamReadsWhatTheBinderBinds(t *testing.T) {
a := zip.New(zip.Config{AppName: "t", DisableStartupMessage: true})
a.Raw("GET", "/v1/t/raw/:name", func(c *zip.Ctx) error { return c.String(200, c.Param("name")) })
req, _ := http.NewRequest("GET", "/v1/t/raw/caf%C3%A9", nil)
resp, err := a.Fiber().Test(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if string(body) != "café" {
t.Errorf("Param = %q, want %q", body, "café")
}
}