Files
zip/queryparam_test.go
T
zeekay 3fff376dfd A repeated argument rides a URL, and the document names what the binder fills
Two halves of one rule, and the second is what made the first safe to rely on.

A URL carries one value per name, so a repeated argument is written as one value
with commas — style: form, explode: false, the spelling the document already
publishes for it. The binder had no case for a slice, so an op taking a list of
ids answered 200 about an empty list. Each element is written by setScalar, so a
list of ids says nothing about ids a second time, and a []byte is not a list
here: encoding/json spells it as one base64 string.

urlFields had the kind switch written a SECOND time, and the second one drifted
the moment setText taught the binder to read an id. The binder filled a
parameter the document did not name, which is the same disagreement as before
with the sides swapped — a caller reading the document had no way to know the
argument existed. Both now read urlSchema, so what is published and what is
bound are one answer. A type that reads itself from text is a string there,
whatever it is made of: describing an id by its 32 bytes published an array of
integers for a value nothing spells that way.
2026-08-29 22:11:11 -07:00

303 lines
9.7 KiB
Go

package zip_test
// The query string is half the URL, and until a typed op could read it, every
// route carrying `?q=` had to stay an untyped handler — invisible to OpenAPI and
// absent from MCP. These pin the binder's contract: what binds, from where, and
// who wins when two sources name the same field.
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"github.com/zap-proto/zip"
)
func stringReader(s string) io.Reader { return strings.NewReader(s) }
// searchIn exercises every kind the URL binder converts, plus a body-only
// field, plus a path param that shares its name with a query key.
type searchIn struct {
Owner string `json:"owner"` // path param on the member route
Q string `json:"q"`
Limit int `json:"limit"`
Offset uint32 `json:"offset"`
Score float64 `json:"score"`
Debug bool `json:"debug"`
Tags []string `json:"tags"` // one value, comma-joined
Filters map[string]string `json:"filters"` // no URL spelling at all
}
type searchOut struct {
Owner string `json:"owner"`
Q string `json:"q"`
Limit int `json:"limit"`
Offset uint32 `json:"offset"`
Score float64 `json:"score"`
Debug bool `json:"debug"`
Tags []string `json:"tags"`
Filters map[string]string `json:"filters"`
}
func echoSearch(_ context.Context, in *searchIn) (*searchOut, error) {
return &searchOut{
Owner: in.Owner, Q: in.Q, Limit: in.Limit, Offset: in.Offset,
Score: in.Score, Debug: in.Debug, Tags: in.Tags, Filters: in.Filters,
}, nil
}
func searchApp(t *testing.T) *zip.App {
t.Helper()
a := zip.New(zip.Config{AppName: "t", DisableStartupMessage: true})
zip.Get(a, "/v1/t/search", echoSearch)
zip.Get(a, "/v1/t/orgs/:owner/search", echoSearch)
zip.Post(a, "/v1/t/orgs/:owner/search", echoSearch)
return a
}
func getSearch(t *testing.T, a *zip.App, method, path, body string) (int, searchOut) {
t.Helper()
var r io.Reader
if body != "" {
r = stringReader(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()
raw, _ := io.ReadAll(resp.Body)
var out searchOut
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("%s %s: response is not %T: %s", method, path, out, raw)
}
return resp.StatusCode, out
}
// A GET typed op reads its whole input from the URL.
func TestTypedGetBindsQueryString(t *testing.T) {
a := searchApp(t)
status, out := getSearch(t, a, "GET",
"/v1/t/search?q=hello+world&limit=25&offset=100&score=1.5&debug=true", "")
if status != 200 {
t.Fatalf("status = %d, want 200", status)
}
if out.Q != "hello world" {
t.Errorf("q = %q, want %q", out.Q, "hello world")
}
if out.Limit != 25 {
t.Errorf("limit = %d, want 25", out.Limit)
}
if out.Offset != 100 {
t.Errorf("offset = %d, want 100", out.Offset)
}
if out.Score != 1.5 {
t.Errorf("score = %v, want 1.5", out.Score)
}
if !out.Debug {
t.Errorf("debug = false, want true")
}
}
// A bare flag is present-means-true, the convention forms and CLIs already use.
func TestTypedGetBindsBareBoolFlag(t *testing.T) {
a := searchApp(t)
if _, out := getSearch(t, a, "GET", "/v1/t/search?debug", ""); !out.Debug {
t.Errorf("?debug did not set the flag")
}
if _, out := getSearch(t, a, "GET", "/v1/t/search?debug=false", ""); out.Debug {
t.Errorf("?debug=false set the flag")
}
}
// One bad scalar does not fail the call. `?limit=abc` is a typo about ONE field;
// refusing the request would make a typed GET brittler than the untyped handler
// it replaced.
func TestTypedGetIgnoresUnparseableScalar(t *testing.T) {
a := searchApp(t)
status, out := getSearch(t, a, "GET", "/v1/t/search?q=ok&limit=abc", "")
if status != 200 {
t.Fatalf("status = %d, want 200 (a bad scalar is not a bad request)", status)
}
if out.Q != "ok" {
t.Errorf("q = %q, want the good field still bound", out.Q)
}
if out.Limit != 0 {
t.Errorf("limit = %d, want the zero value", out.Limit)
}
}
// Unknown keys are ordinary — callers append tracking params — so they are
// ignored, never rejected.
func TestTypedGetIgnoresUnknownQueryKeys(t *testing.T) {
a := searchApp(t)
status, out := getSearch(t, a, "GET", "/v1/t/search?q=ok&utm_source=x&nope=1", "")
if status != 200 {
t.Fatalf("status = %d, want 200", status)
}
if out.Q != "ok" {
t.Errorf("q = %q, want %q", out.Q, "ok")
}
}
// A repeated field rides as one comma-joined value — `style: form, explode:
// false`, the spelling the document publishes for it and the only one a binder
// given one value per name can read.
func TestTypedGetBindsAListFromOneValue(t *testing.T) {
a := searchApp(t)
_, out := getSearch(t, a, "GET", "/v1/t/search?tags=a,b", "")
if len(out.Tags) != 2 || out.Tags[0] != "a" || out.Tags[1] != "b" {
t.Errorf("tags = %v, want [a b]", out.Tags)
}
}
// A map has no URL spelling and is left alone — and the document does not name
// it either, so no client is offered an argument the wire ignores.
func TestTypedGetLeavesAMapUnbound(t *testing.T) {
a := searchApp(t)
if _, out := getSearch(t, a, "GET", "/v1/t/search?filters=a:b", ""); out.Filters != nil {
t.Errorf("filters = %v, want nil (a map is not URL-bindable)", out.Filters)
}
}
// The URL is the addressing authority: the path segment the router MATCHED on
// beats a query key of the same name.
func TestPathParamBeatsQueryParam(t *testing.T) {
a := searchApp(t)
_, out := getSearch(t, a, "GET", "/v1/t/orgs/acme/search?owner=evil&q=x", "")
if out.Owner != "acme" {
t.Errorf("owner = %q, want %q — the routed path must win", out.Owner, "acme")
}
}
// ...and the query beats the body, because it is part of the URL; the path
// still beats both.
func TestQueryBeatsBodyAndPathBeatsQuery(t *testing.T) {
a := searchApp(t)
_, out := getSearch(t, a, "POST", "/v1/t/orgs/acme/search?q=fromquery",
`{"owner":"evil","q":"frombody","limit":7}`)
if out.Owner != "acme" {
t.Errorf("owner = %q, want %q — path wins", out.Owner, "acme")
}
if out.Q != "fromquery" {
t.Errorf("q = %q, want %q — query beats body", out.Q, "fromquery")
}
if out.Limit != 7 {
t.Errorf("limit = %d, want 7 — a body-only field still binds", out.Limit)
}
}
// The document must name every query parameter the binder reads, or it
// describes a route nobody can call correctly.
func TestOpenAPIDeclaresQueryParams(t *testing.T) {
a := searchApp(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, _ := paths["/v1/t/search"].(map[string]any)
get, _ := item["get"].(map[string]any)
params, _ := get["parameters"].([]any)
got := map[string]map[string]any{}
for _, raw := range params {
p, _ := raw.(map[string]any)
got[p["name"].(string)] = p
}
for name, wantType := range map[string]string{
"q": "string", "limit": "integer", "offset": "integer",
"score": "number", "debug": "boolean", "owner": "string",
"tags": "array",
} {
p, ok := got[name]
if !ok {
t.Errorf("query parameter %q missing from the document", name)
continue
}
if p["in"] != "query" {
t.Errorf("parameter %q in = %v, want query", name, p["in"])
}
schema, _ := p["schema"].(map[string]any)
if schema["type"] != wantType {
t.Errorf("parameter %q type = %v, want %s", name, schema["type"], wantType)
}
}
// What the binder cannot fill is not declared.
if _, ok := got["filters"]; ok {
t.Errorf("declared a query parameter the binder cannot bind")
}
// A POST declares a requestBody instead of query parameters — the two are
// the same rule (hasBody) read from opposite sides.
postItem, _ := paths["/v1/t/orgs/{owner}/search"].(map[string]any)
post, _ := postItem["post"].(map[string]any)
if _, ok := post["requestBody"]; !ok {
t.Errorf("POST has no requestBody")
}
for _, raw := range post["parameters"].([]any) {
p, _ := raw.(map[string]any)
if p["in"] != "path" {
t.Errorf("POST declares %v; a body method takes no query params", p)
}
}
}
// MCP is unaffected: a tools/call carries every argument in its JSON object, so
// the tool's input is the whole struct with no URL involved.
func TestMCPCallIsUnaffectedByQueryBinding(t *testing.T) {
a := searchApp(t)
if err := a.Build(); err != nil {
t.Fatalf("Build: %v", err)
}
req, _ := http.NewRequest("POST", "/mcp", stringReader(
`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_t_search","arguments":{"q":"viajson","limit":9}}}`))
req.Header.Set("Content-Type", "application/json")
resp, err := a.Fiber().Test(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
var env struct {
Result struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
IsError bool `json:"isError"`
} `json:"result"`
}
if err := json.Unmarshal(raw, &env); err != nil {
t.Fatalf("mcp response is not json: %s", raw)
}
if env.Result.IsError || len(env.Result.Content) == 0 {
t.Fatalf("tools/call failed: %s", raw)
}
var out searchOut
if err := json.Unmarshal([]byte(env.Result.Content[0].Text), &out); err != nil {
t.Fatalf("tool result is not %T: %s", out, env.Result.Content[0].Text)
}
if out.Q != "viajson" || out.Limit != 9 {
t.Errorf("tool result = %+v, want the JSON arguments bound", out)
}
}