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]>
303 lines
9.6 KiB
Go
303 lines
9.6 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})
|
|
a.Get("/v1/t/search", echoSearch)
|
|
a.Get("/v1/t/orgs/:owner/search", echoSearch)
|
|
a.Post("/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)
|
|
}
|
|
}
|