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]>
126 lines
4.0 KiB
Go
126 lines
4.0 KiB
Go
package zip_test
|
|
|
|
// A URL carries one value per name, so a REPEATED argument is written as one
|
|
// value with commas — `style: form, explode: false`, which is what the document
|
|
// publishes for it. Until the binder read that spelling, an op taking a list of
|
|
// ids answered 200 about an empty list. These pin the spelling, and pin the
|
|
// document to naming exactly what the binder fills.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"testing"
|
|
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
type listIn struct {
|
|
IDs []hash `json:"ids"`
|
|
Names []string `json:"names"`
|
|
Sizes []int `json:"sizes"`
|
|
By map[string]string `json:"by"`
|
|
}
|
|
|
|
func echoList(_ context.Context, in *listIn) (*listIn, error) { return in, nil }
|
|
|
|
func listApp() *zip.App {
|
|
a := zip.New(zip.Config{AppName: "list", DisableStartupMessage: true})
|
|
a.Get("/v1/list/things", echoList)
|
|
return a
|
|
}
|
|
|
|
func askList(t *testing.T, query string) listIn {
|
|
t.Helper()
|
|
code, body := call2(t, listApp(), "GET", "/v1/list/things?"+query, "")
|
|
if code != 200 {
|
|
t.Fatalf("status = %d %s", code, body)
|
|
}
|
|
var got listIn
|
|
if err := json.Unmarshal([]byte(body), &got); err != nil {
|
|
t.Fatalf("body %s: %v", body, err)
|
|
}
|
|
return got
|
|
}
|
|
|
|
// A repeated field arrives, and its elements are read the same way a bare one
|
|
// would be — so a list of ids says nothing about ids a second time.
|
|
func TestAURLCarriesARepeatedValue(t *testing.T) {
|
|
one, two := hash{0x11}, hash{0x22}
|
|
got := askList(t, "ids="+hex.EncodeToString(one[:])+","+hex.EncodeToString(two[:])+
|
|
"&names=a,b&sizes=1,2,3")
|
|
|
|
if len(got.IDs) != 2 || got.IDs[0] != one || got.IDs[1] != two {
|
|
t.Errorf("ids = %x, want the two named", got.IDs)
|
|
}
|
|
if len(got.Names) != 2 || got.Names[0] != "a" || got.Names[1] != "b" {
|
|
t.Errorf("names = %v", got.Names)
|
|
}
|
|
if len(got.Sizes) != 3 || got.Sizes[2] != 3 {
|
|
t.Errorf("sizes = %v", got.Sizes)
|
|
}
|
|
}
|
|
|
|
// One element is still a list. A caller asking about a single id sends one
|
|
// value, and an op that took a list only when given two would be a trap.
|
|
func TestOneValueIsAListOfOne(t *testing.T) {
|
|
if got := askList(t, "names=solo"); len(got.Names) != 1 || got.Names[0] != "solo" {
|
|
t.Errorf("names = %v, want [solo]", got.Names)
|
|
}
|
|
}
|
|
|
|
// A map is not a list with different punctuation. It has no URL spelling, so
|
|
// the binder leaves it alone and the document does not name it.
|
|
func TestAMapDoesNotRideAURL(t *testing.T) {
|
|
if got := askList(t, "by=k:v"); got.By != nil {
|
|
t.Errorf("by = %v, want nil", got.By)
|
|
}
|
|
}
|
|
|
|
// The other half of the same rule: the document names exactly what the binder
|
|
// fills, so a generated client's arguments and the server's binding cannot
|
|
// disagree. An id is a STRING there — one word in the URL and the same word in
|
|
// the JSON beside it, whatever it is made of — and a list of ids is an array of
|
|
// that string. Describing it by what it is MADE of published an array of
|
|
// integers for a value nothing spells that way.
|
|
func TestTheDocumentNamesWhatTheBinderFills(t *testing.T) {
|
|
type oneIn struct {
|
|
ID hash `json:"id"`
|
|
}
|
|
a := listApp()
|
|
a.Get("/v1/list/one", func(_ context.Context, in *oneIn) (*oneIn, error) { return in, nil })
|
|
|
|
says := func(path string) map[string]string {
|
|
t.Helper()
|
|
out := map[string]string{}
|
|
for _, raw := range paramsOf2(t, a, path, "get") {
|
|
p, _ := raw.(map[string]any)
|
|
schema, _ := p["schema"].(map[string]any)
|
|
kind, _ := schema["type"].(string)
|
|
if kind == "array" {
|
|
items, _ := schema["items"].(map[string]any)
|
|
kind += " of " + items["type"].(string)
|
|
}
|
|
out[p["name"].(string)] = kind
|
|
}
|
|
return out
|
|
}
|
|
|
|
list := says("/v1/list/things")
|
|
for name, want := range map[string]string{
|
|
"ids": "array of string",
|
|
"names": "array of string",
|
|
"sizes": "array of integer",
|
|
} {
|
|
if list[name] != want {
|
|
t.Errorf("%s: document says %q, want %q", name, list[name], want)
|
|
}
|
|
}
|
|
if _, named := list["by"]; named {
|
|
t.Errorf("the document names `by`, which the binder cannot fill")
|
|
}
|
|
if one := says("/v1/list/one"); one["id"] != "string" {
|
|
t.Errorf("id: document says %q, want a string", one["id"])
|
|
}
|
|
}
|