Files
zip/bodyless_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

238 lines
8.8 KiB
Go

package zip_test
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/zap-proto/zip"
)
// dropIn is a DELETE's input: the URL addresses what is deleted and carries the
// rest of the request beside it.
type dropIn struct {
ID string `json:"id"`
Reason string `json:"reason" validate:"required"`
Force bool `json:"force"`
}
type dropOut struct {
ID string `json:"id"`
Reason string `json:"reason"`
Force bool `json:"force"`
}
func drop(_ context.Context, in *dropIn) (*dropOut, error) {
return &dropOut{ID: in.ID, Reason: in.Reason, Force: in.Force}, nil
}
func dropApp(t *testing.T) *zip.App {
t.Helper()
a := zip.New(zip.Config{AppName: "drop", DisableStartupMessage: true})
zip.Describe("DELETE /v1/drop/things/:id", zip.Doc{
Description: "Drop deletes a thing.",
Fields: map[string]string{"dropIn.reason": "Why it is being deleted."},
Example: json.RawMessage(`{"id":"t_1","reason":"expired"}`),
})
a.Delete("/v1/drop/things/:id", drop)
return a
}
// A DELETE carries no body — hasBody said so, the document said so, and the
// route read one anyway. It now reads the URL, which is what every client
// generated from the document sends.
func TestBodyless_DeleteReadsTheURLNotTheBody(t *testing.T) {
a := dropApp(t)
code, body := call2(t, a, "DELETE", "/v1/drop/things/t_1?reason=expired&force=true", "")
if code != 200 {
t.Fatalf("DELETE with a query = %d %s, want 200", code, body)
}
for _, want := range []string{`"id":"t_1"`, `"reason":"expired"`, `"force":true`} {
if !strings.Contains(body, want) {
t.Errorf("body %s is missing %s", body, want)
}
}
// And a body is no longer an input. It is not an error to send one — the
// method simply does not carry one — so the request is refused by the
// validator for the field it never received, not by the decoder.
code, body = call2(t, a, "DELETE", "/v1/drop/things/t_1", `{"reason":"smuggled"}`)
if code != 400 || !strings.Contains(body, "reason") {
t.Fatalf("DELETE with only a body = %d %s, want 400 naming the missing field", code, body)
}
}
// The document types a URL-borne field's required-ness from the same
// `validate:"required"` that makes the handler refuse without it. Declaring
// every query parameter optional described a call that cannot succeed, and every
// generated client made the argument optional to match.
func TestBodyless_RequiredReachesTheParameter(t *testing.T) {
params := paramsOf2(t, dropApp(t), "/v1/drop/things/{id}", "delete")
want := map[string]bool{"id": true, "reason": true, "force": false}
if len(params) != len(want) {
t.Fatalf("parameters = %v, want %d", params, len(want))
}
for _, raw := range params {
p, _ := raw.(map[string]any)
name, _ := p["name"].(string)
if p["required"] != want[name] {
t.Errorf("%q required = %v, want %v", name, p["required"], want[name])
}
}
}
// The doc comment's example survives a bodyless op, spread across the parameters
// that carry it — the only place a document has to put it when there is no
// requestBody. Without this, every GET and DELETE reached the published
// reference and the generated CLI with no example at all.
func TestBodyless_ExampleSurvivesAsParameterExamples(t *testing.T) {
a := dropApp(t)
got := map[string]string{}
for _, raw := range paramsOf2(t, a, "/v1/drop/things/{id}", "delete") {
p, _ := raw.(map[string]any)
if ex, ok := p["example"]; ok {
got[p["name"].(string)] = strings.Trim(string(mustJSON(t, ex)), `"`)
}
}
if got["id"] != "t_1" || got["reason"] != "expired" {
t.Fatalf("parameter examples = %v, want the doc comment's own example", got)
}
// `force` was not in the example, so it carries none — an example is what
// was written, not a value invented per field.
if _, ok := got["force"]; ok {
t.Errorf("force carries an example it was never given: %v", got)
}
// And it round-trips: the client tree rebuilt from the document renders the
// same example command line as the one built from the registry.
spec, err := json.Marshal(a.OpenAPISpec())
if err != nil {
t.Fatal(err)
}
fromSpec, err := zip.CommandsFromSpec(spec)
if err != nil {
t.Fatalf("CommandsFromSpec: %v", err)
}
if len(fromSpec) != 1 || !sameJSON(fromSpec[0].Example, a.Commands()[0].Example) {
t.Fatalf("spec example %s, registry example %s", fromSpec[0].Example, a.Commands()[0].Example)
}
}
// A bodyless op's flags are what the URL can actually carry. `--filters` on a
// DELETE marshals fine, goes out as a query value and is dropped by the binder:
// a flag the CLI offers and the wire ignores. A repeated field is not that case
// — it rides as one comma-joined value — but a map has no URL spelling at all.
func TestBodyless_FlagsAreWhatTheURLCanCarry(t *testing.T) {
type wideIn struct {
ID string `json:"id"`
Note string `json:"note"`
Tags []string `json:"tags"`
Filters map[string]string `json:"filters"`
}
a := zip.New(zip.Config{AppName: "wide", DisableStartupMessage: true})
a.Delete("/v1/wide/things/:id", func(_ context.Context, in *wideIn) (*wideIn, error) { return in, nil })
a.Post("/v1/wide/things", func(_ context.Context, in *wideIn) (*wideIn, error) { return in, nil })
byName := map[string][]string{}
for _, c := range a.Commands() {
for _, f := range c.Flags {
byName[c.Name] = append(byName[c.Name], f.Name)
}
}
if got := byName["things-delete"]; len(got) != 2 || got[0] != "note" || got[1] != "tags" {
t.Errorf("DELETE flags = %v, want --note and --tags — a map cannot ride a URL", got)
}
if got := byName["things-create"]; len(got) != 4 {
t.Errorf("POST flags = %v, want all four — a body carries anything", got)
}
}
// verifyIn addresses its target entirely with the URL. There is nothing left for
// a body to carry.
type verifyIn struct {
ID string `json:"id"`
}
type verifyOut struct {
Verified bool `json:"verified"`
}
func verifyApp() *zip.App {
a := zip.New(zip.Config{AppName: "verify", DisableStartupMessage: true})
a.Post("/v1/drop/things/:id/verify", func(_ context.Context, in *verifyIn) (*verifyOut, error) {
return &verifyOut{Verified: in.ID == "t_1"}, nil
})
return a
}
// A POST whose input is nothing but its path parameters has no request body, and
// publishing one anyway put a REQUIRED argument into every generated SDK whose
// only properties were the path params the caller already passes. The body was
// never read for anything — bindURL binds the path last — so the phantom was
// only ever in the document.
func TestBodyless_PostWithOnlyPathParamsPublishesNoBody(t *testing.T) {
a := verifyApp()
spec := a.OpenAPISpec()
paths, _ := spec["paths"].(map[string]map[string]any)
op, _ := paths["/v1/drop/things/{id}/verify"]["post"].(map[string]any)
if op == nil {
t.Fatalf("no POST in spec; paths = %v", keysOf(anyMap2(paths)))
}
if rb, ok := op["requestBody"]; ok {
t.Errorf("requestBody = %v, want none — the URL is the whole input", rb)
}
// And no query parameters either: every field IS a path param, so there is
// nothing to declare a second way.
params, _ := op["parameters"].([]any)
if len(params) != 1 {
t.Fatalf("parameters = %v, want just the path param", params)
}
// The CLI agrees, from the same registry: one positional, no flags.
cmds := a.Commands()
if len(cmds) != 1 || len(cmds[0].Args) != 1 || len(cmds[0].Flags) != 0 {
t.Errorf("command = %+v, want one arg and no flags", cmds[0])
}
}
// The WIRE is untouched. POST still carries a body on the wire, so the route
// still reads one and still refuses one it cannot parse. Only the document
// stopped claiming an argument that does nothing.
func TestBodyless_PostStillReadsTheWireItAlwaysDid(t *testing.T) {
a := verifyApp()
if code, body := call2(t, a, "POST", "/v1/drop/things/t_1/verify", ""); code != 200 || !strings.Contains(body, `"verified":true`) {
t.Errorf("POST with no body = %d %s, want 200 verified", code, body)
}
// The URL is the addressing authority: a body that repeats a path param
// never won, which is exactly why removing it from the document is safe.
if code, body := call2(t, a, "POST", "/v1/drop/things/t_1/verify", `{"id":"t_9"}`); code != 200 || !strings.Contains(body, `"verified":true`) {
t.Errorf("POST with a smuggled id = %d %s, want the URL's id to win", code, body)
}
if code, _ := call2(t, a, "POST", "/v1/drop/things/t_1/verify", `{`); code != 400 {
t.Errorf("POST with a malformed body = %d, want the 400 it has always answered", code)
}
}
func paramsOf2(t *testing.T, a *zip.App, path, method string) []any {
t.Helper()
paths, _ := a.OpenAPISpec()["paths"].(map[string]map[string]any)
item, ok := paths[path]
if !ok {
t.Fatalf("no path %q in spec; paths = %v", path, keysOf(anyMap2(paths)))
}
op, _ := item[method].(map[string]any)
params, _ := op["parameters"].([]any)
return params
}
func mustJSON(t *testing.T, v any) []byte {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return b
}