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]>
415 lines
14 KiB
Go
415 lines
14 KiB
Go
package zip_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/zap-proto/zip"
|
|
"github.com/zap-proto/zip/middleware"
|
|
)
|
|
|
|
// answered drives one request and returns what arrived: status, headers, and
|
|
// the body already parsed. Every test here reads the WIRE — a rendering asserted
|
|
// against the function that produced it proves only that the function is itself.
|
|
func answered(t *testing.T, app *zip.App, method, path string) (*http.Response, map[string]any) {
|
|
t.Helper()
|
|
req, err := http.NewRequest(method, path, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, _ := io.ReadAll(res.Body)
|
|
var body map[string]any
|
|
if err := json.Unmarshal(raw, &body); err != nil {
|
|
t.Fatalf("%s %s: body is not JSON: %s", method, path, raw)
|
|
}
|
|
return res, body
|
|
}
|
|
|
|
func refusalApp(t *testing.T) *zip.App {
|
|
t.Helper()
|
|
app := zip.New(zip.Config{AppName: "refusals", DisableStartupMessage: true})
|
|
app.Use(middleware.Recover())
|
|
app.Raw("GET", "/thing/:id", func(c *zip.Ctx) error { return c.JSON(200, map[string]any{"ok": true}) })
|
|
app.Raw("PUT", "/thing/:id", func(c *zip.Ctx) error { return c.JSON(200, map[string]any{"ok": true}) })
|
|
app.Raw("GET", "/panic", func(c *zip.Ctx) error { panic("nil map write") })
|
|
app.Raw("GET", "/capped", func(c *zip.Ctx) error {
|
|
return zip.ErrPaymentRequired("spend cap exceeded").
|
|
With(map[string]any{"cap": 5000, "spent": 5127})
|
|
})
|
|
|
|
oauth := app.OAuth()
|
|
oauth.Raw("POST", "/v1/oauth/token", func(c *zip.Ctx) error {
|
|
return &zip.HTTPError{Status: 400, Code: "invalid_grant", Msg: "code already redeemed"}
|
|
})
|
|
oauth.Raw("POST", "/v1/oauth/introspect", func(c *zip.Ctx) error { panic("store closed") })
|
|
return app
|
|
}
|
|
|
|
// TestARefusalIsAProblemDocument is RFC 9457 on the wire: the registered media
|
|
// type, and the five members a client that has never seen this service can read.
|
|
func TestARefusalIsAProblemDocument(t *testing.T) {
|
|
res, body := answered(t, refusalApp(t), "GET", "/capped")
|
|
|
|
if res.StatusCode != 402 {
|
|
t.Fatalf("status = %d, want 402", res.StatusCode)
|
|
}
|
|
if ct := res.Header.Get("Content-Type"); ct != "application/problem+json" {
|
|
t.Errorf("Content-Type = %q, want application/problem+json — the suffix is what says the members mean what RFC 9457 says", ct)
|
|
}
|
|
for member, want := range map[string]any{
|
|
"type": "about:blank",
|
|
"title": "Payment Required",
|
|
"status": float64(402),
|
|
"detail": "spend cap exceeded",
|
|
} {
|
|
if body[member] != want {
|
|
t.Errorf("%s = %v, want %v", member, body[member], want)
|
|
}
|
|
}
|
|
// Extension members sit BESIDE the document's own (§3.2), not nested under a
|
|
// key a client would have to be told about.
|
|
if body["cap"] != float64(5000) || body["spent"] != float64(5127) {
|
|
t.Errorf("extension members did not merge: %v", body)
|
|
}
|
|
}
|
|
|
|
// TestAWrongVerbSaysWhichVerbsWork is RFC 9110 §15.5.6: a 405 MUST carry Allow.
|
|
// Without it a client cannot tell a wrong verb from a wrong address, and the two
|
|
// have opposite fixes.
|
|
func TestAWrongVerbSaysWhichVerbsWork(t *testing.T) {
|
|
res, body := answered(t, refusalApp(t), "DELETE", "/thing/7")
|
|
|
|
if res.StatusCode != 405 {
|
|
t.Fatalf("status = %d, want 405", res.StatusCode)
|
|
}
|
|
allow := res.Header.Values("Allow")
|
|
if len(allow) == 0 {
|
|
t.Fatal("no Allow header on a 405")
|
|
}
|
|
for _, want := range []string{"GET", "HEAD", "PUT"} {
|
|
found := false
|
|
for _, got := range allow {
|
|
for _, m := range splitList(got) {
|
|
if m == want {
|
|
found = true
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("Allow %v omits %s, which the address does serve", allow, want)
|
|
}
|
|
}
|
|
for _, m := range []string{"DELETE", "POST", "PATCH"} {
|
|
for _, got := range allow {
|
|
for _, listed := range splitList(got) {
|
|
if listed == m {
|
|
t.Errorf("Allow %v lists %s, which the address does not serve", allow, m)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if body["status"] != float64(405) || body["title"] != "Method Not Allowed" {
|
|
t.Errorf("405 body is not a problem document: %v", body)
|
|
}
|
|
}
|
|
|
|
func splitList(s string) []string {
|
|
var out []string
|
|
cur := ""
|
|
for _, r := range s {
|
|
switch r {
|
|
case ',', ' ':
|
|
if cur != "" {
|
|
out = append(out, cur)
|
|
cur = ""
|
|
}
|
|
default:
|
|
cur += string(r)
|
|
}
|
|
}
|
|
if cur != "" {
|
|
out = append(out, cur)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestAPanicIsAProblemDocument pins that a refusal nobody wrote leaves by the
|
|
// same door as one somebody did — Recover returns it rather than writing a body
|
|
// of its own, so it gets the media type and the vocabulary the address answers
|
|
// in.
|
|
func TestAPanicIsAProblemDocument(t *testing.T) {
|
|
res, body := answered(t, refusalApp(t), "GET", "/panic")
|
|
|
|
if res.StatusCode != 500 {
|
|
t.Fatalf("status = %d, want 500", res.StatusCode)
|
|
}
|
|
if ct := res.Header.Get("Content-Type"); ct != "application/problem+json" {
|
|
t.Errorf("Content-Type = %q, want application/problem+json", ct)
|
|
}
|
|
if body["status"] != float64(500) {
|
|
t.Errorf("panic did not render as a problem document: %v", body)
|
|
}
|
|
}
|
|
|
|
// TestTheOAuthFamilyKeepsItsOwnVocabulary is the carve-out, and it is not a
|
|
// preference: RFC 6749 §5.2 states {error, error_description} under
|
|
// application/json, and RFC 7662 §2.3 and RFC 7009 §2.2.1 point at that section.
|
|
// A problem document here is a broken authorization server.
|
|
func TestTheOAuthFamilyKeepsItsOwnVocabulary(t *testing.T) {
|
|
res, body := answered(t, refusalApp(t), "POST", "/v1/oauth/token")
|
|
|
|
if res.StatusCode != 400 {
|
|
t.Fatalf("status = %d, want 400", res.StatusCode)
|
|
}
|
|
if ct := res.Header.Get("Content-Type"); ct != "application/json" {
|
|
t.Errorf("Content-Type = %q, want application/json — RFC 6749 §5.2", ct)
|
|
}
|
|
if body["error"] != "invalid_grant" || body["error_description"] != "code already redeemed" {
|
|
t.Errorf("token endpoint answered %v, which no OAuth client parses", body)
|
|
}
|
|
for _, member := range []string{"type", "title", "detail", "status"} {
|
|
if _, ok := body[member]; ok {
|
|
t.Errorf("problem member %q leaked into an OAuth error", member)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAnOAuthAddressSpeaksOAuthForRefusalsItDidNotWrite is why the vocabulary is
|
|
// a property of the ADDRESS and not of the returned value. A panic, a body that
|
|
// will not bind, a gate refusing in front of the handler: no handler is there to
|
|
// choose a shape for any of them, and they are exactly the requests where a
|
|
// client is already confused.
|
|
func TestAnOAuthAddressSpeaksOAuthForRefusalsItDidNotWrite(t *testing.T) {
|
|
res, body := answered(t, refusalApp(t), "POST", "/v1/oauth/introspect")
|
|
|
|
if res.StatusCode != 500 {
|
|
t.Fatalf("status = %d, want 500", res.StatusCode)
|
|
}
|
|
if body["error"] != "server_error" {
|
|
t.Errorf("error = %v, want the RFC 6749 code for a failure that is the server's own", body["error"])
|
|
}
|
|
if _, ok := body["type"]; ok {
|
|
t.Errorf("a panic at an OAuth address answered a problem document: %v", body)
|
|
}
|
|
}
|
|
|
|
// TestAWrongVerbAtAnOAuthAddressIsTheRouterRefusing draws the line: the
|
|
// vocabulary belongs to the METHOD+path pair that was registered, so a verb the
|
|
// endpoint does not serve is the router's refusal and carries Allow, which is
|
|
// the answer the client actually needs.
|
|
func TestAWrongVerbAtAnOAuthAddressIsTheRouterRefusing(t *testing.T) {
|
|
res, body := answered(t, refusalApp(t), "GET", "/v1/oauth/token")
|
|
|
|
if res.StatusCode != 405 {
|
|
t.Fatalf("status = %d, want 405", res.StatusCode)
|
|
}
|
|
if got := res.Header.Get("Allow"); got != "POST" {
|
|
t.Errorf("Allow = %q, want POST", got)
|
|
}
|
|
if body["status"] != float64(405) {
|
|
t.Errorf("405 body is not a problem document: %v", body)
|
|
}
|
|
}
|
|
|
|
// TestTheVocabularySurvivesComposition is the reason it rides the route entry
|
|
// and not a table of addresses: a definition included under a host answers at a
|
|
// path it did not know it would have.
|
|
func TestTheVocabularySurvivesComposition(t *testing.T) {
|
|
auth := zip.New(zip.Config{AppName: "auth", DisableStartupMessage: true})
|
|
oauth := auth.OAuth()
|
|
oauth.Raw("POST", "/token", func(c *zip.Ctx) error { return zip.ErrUnauthorized("bad secret") })
|
|
|
|
host := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
|
host.Group("/v1/iam/oauth").Use(auth)
|
|
|
|
res, body := answered(t, host, "POST", "/v1/iam/oauth/token")
|
|
|
|
if res.StatusCode != 401 {
|
|
t.Fatalf("status = %d, want 401", res.StatusCode)
|
|
}
|
|
if body["error"] != "invalid_client" || body["error_description"] != "bad secret" {
|
|
t.Errorf("the composed token endpoint answered %v — the vocabulary did not follow the route", body)
|
|
}
|
|
}
|
|
|
|
// TestAGroupOfTheOAuthRouterIsStillOAuth pins that the vocabulary descends the
|
|
// way a scoped chain does. A nested scope answering its parent's neighbours'
|
|
// shape is the same silently-lost property as a gate dropped at the second
|
|
// nesting, and just as invisible until a client breaks.
|
|
func TestAGroupOfTheOAuthRouterIsStillOAuth(t *testing.T) {
|
|
app := zip.New(zip.Config{AppName: "nested-oauth", DisableStartupMessage: true})
|
|
app.OAuth().Group("/v1/oauth").Raw("POST", "/revoke", func(c *zip.Ctx) error {
|
|
return zip.ErrBadRequest("token is required")
|
|
})
|
|
|
|
res, body := answered(t, app, "POST", "/v1/oauth/revoke")
|
|
|
|
if res.StatusCode != 400 {
|
|
t.Fatalf("status = %d, want 400", res.StatusCode)
|
|
}
|
|
if body["error"] != "invalid_request" || body["error_description"] != "token is required" {
|
|
t.Errorf("a group of the OAuth router answered %v — the vocabulary did not descend", body)
|
|
}
|
|
}
|
|
|
|
// TestOAuthTakesTypedOpsToo pins that the carve-out is a Router in full: an op
|
|
// declared through it is registered, documented and refused the same way an
|
|
// untyped handler is.
|
|
func TestOAuthTakesTypedOpsToo(t *testing.T) {
|
|
app := zip.New(zip.Config{AppName: "typed-oauth", DisableStartupMessage: true})
|
|
type in struct {
|
|
Token string `json:"token"`
|
|
}
|
|
type out struct {
|
|
Active bool `json:"active"`
|
|
}
|
|
app.OAuth().Post("/v1/oauth/revoke", func(_ context.Context, i *in) (*out, error) {
|
|
return nil, zip.ErrBadRequest("token is required")
|
|
})
|
|
|
|
res, body := answered(t, app, "POST", "/v1/oauth/revoke")
|
|
|
|
if res.StatusCode != 400 {
|
|
t.Fatalf("status = %d, want 400", res.StatusCode)
|
|
}
|
|
if body["error"] != "invalid_request" || body["error_description"] != "token is required" {
|
|
t.Errorf("typed op at an OAuth address answered %v", body)
|
|
}
|
|
if len(app.Routes()) != 1 || app.Routes()[0].Op == "" {
|
|
t.Errorf("the op was not registered as an op: %v", app.Routes())
|
|
}
|
|
}
|
|
|
|
// at walks the decoded document to one node, and says which step was not there.
|
|
//
|
|
// A walk over typed structs collapses EVERY structural miss to the zero value,
|
|
// so a document missing a whole response reads exactly like one whose $ref is
|
|
// empty. That is how a green feature and a red test came to disagree with
|
|
// nobody able to say where: the assertion could only report "".
|
|
func at(t *testing.T, tree map[string]any, steps ...string) any {
|
|
t.Helper()
|
|
var node any = tree
|
|
for i, step := range steps {
|
|
obj, ok := node.(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("%s is a %T, not an object", strings.Join(steps[:i], " → "), node)
|
|
}
|
|
next, ok := obj[step]
|
|
if !ok {
|
|
have := make([]string, 0, len(obj))
|
|
for k := range obj {
|
|
have = append(have, k)
|
|
}
|
|
sort.Strings(have)
|
|
t.Fatalf("%s has no %q; it has %v", strings.Join(steps[:i], " → "), step, have)
|
|
}
|
|
node = next
|
|
}
|
|
return node
|
|
}
|
|
|
|
// text is [at] where the document says a string.
|
|
func text(t *testing.T, tree map[string]any, steps ...string) string {
|
|
t.Helper()
|
|
node := at(t, tree, steps...)
|
|
s, ok := node.(string)
|
|
if !ok {
|
|
t.Fatalf("%s is a %T, not a string", strings.Join(steps, " → "), node)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// TestTheDocumentDeclaresTheRefusal: every op publishes a `default` response in
|
|
// the vocabulary its address speaks, so a generated client types its errors —
|
|
// and the refusal on the wire carries every member that schema requires.
|
|
func TestTheDocumentDeclaresTheRefusal(t *testing.T) {
|
|
type in struct {
|
|
Name string `json:"name"`
|
|
}
|
|
type out struct {
|
|
OK bool `json:"ok"`
|
|
}
|
|
ok := func(context.Context, *in) (*out, error) { return &out{OK: true}, nil }
|
|
app := zip.New(zip.Config{AppName: "declared", DisableStartupMessage: true})
|
|
app.Post("/v1/things", ok)
|
|
app.OAuth().Post("/v1/oauth/token", ok)
|
|
|
|
raw, err := json.Marshal(app.OpenAPISpec())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var doc map[string]any
|
|
if err := json.Unmarshal(raw, &doc); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for _, c := range []struct{ path, media, schema string }{
|
|
{"/v1/things", "application/problem+json", "problem-details"},
|
|
{"/v1/oauth/token", "application/json", "oauth-error"},
|
|
} {
|
|
want := "#/components/schemas/" + c.schema
|
|
if ref := text(t, doc, "paths", c.path, "post", "responses", "default", "content", c.media, "schema", "$ref"); ref != want {
|
|
t.Errorf("%s: default %s schema is %q, want %s", c.path, c.media, ref, want)
|
|
continue
|
|
}
|
|
req, err := http.NewRequest("POST", c.path, strings.NewReader("{"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := res.Header.Get("Content-Type"); got != c.media {
|
|
t.Errorf("%s: Content-Type %q, the document says %s", c.path, got, c.media)
|
|
}
|
|
b, _ := io.ReadAll(res.Body)
|
|
var body map[string]any
|
|
if err := json.Unmarshal(b, &body); err != nil {
|
|
t.Fatalf("%s: %s", c.path, b)
|
|
}
|
|
for _, member := range at(t, doc, "components", "schemas", c.schema, "required").([]any) {
|
|
if _, ok := body[member.(string)]; !ok {
|
|
t.Errorf("%s: the refusal has no %v, which the document requires: %s", c.path, member, b)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// A REFUSAL SAYS NOTHING ABOUT WHERE IT HAPPENED, so two addresses that refuse
|
|
// for the same reason answer identically. That is what lets "a real name and an
|
|
// invented one are answered the same" be compared strictly rather than loosened
|
|
// until it passes. RFC 9457 §3.1.4 makes `instance` optional; filling it with the
|
|
// request path would tell the caller what the caller just said.
|
|
func TestTwoAddressesRefuseIdentically(t *testing.T) {
|
|
a := zip.New(zip.Config{AppName: "same", DisableStartupMessage: true})
|
|
deny := func(c *zip.Ctx) error { return zip.ErrNotFound("no such thing") }
|
|
a.Raw("GET", "/thing/real", deny)
|
|
a.Raw("GET", "/quite/another", deny)
|
|
if err := a.Build(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
one, oneBody := answered(t, a, "GET", "/thing/real")
|
|
two, twoBody := answered(t, a, "GET", "/quite/another")
|
|
if one.StatusCode != 404 || two.StatusCode != 404 {
|
|
t.Fatalf("status = %d and %d, want 404", one.StatusCode, two.StatusCode)
|
|
}
|
|
if _, ok := oneBody["instance"]; ok {
|
|
t.Errorf("the document names its occurrence: %v", oneBody)
|
|
}
|
|
x, _ := json.Marshal(oneBody)
|
|
y, _ := json.Marshal(twoBody)
|
|
if string(x) != string(y) {
|
|
t.Errorf("two addresses refused differently:\n %s\n %s", x, y)
|
|
}
|
|
}
|