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]>
236 lines
7.9 KiB
Go
236 lines
7.9 KiB
Go
// Copyright 2026 Hanzo AI, Inc.
|
|
// SPDX-License-Identifier: MIT OR Apache-2.0
|
|
|
|
package zip
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// linkIn takes nothing; linkOut is the smallest shape that makes the op typed,
|
|
// which is what turns an app's description on.
|
|
type linkIn struct{}
|
|
|
|
type linkOut struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// linked builds an app that serves a document — one typed op is what turns the
|
|
// description on — and returns its response to one GET.
|
|
func linked(t *testing.T, path string) *http.Response {
|
|
t.Helper()
|
|
a := quiet("linked")
|
|
a.Get("/thing", func(ctx context.Context, _ *linkIn) (*linkOut, error) {
|
|
return &linkOut{Name: "thing"}, nil
|
|
})
|
|
a.installOpenAPIRoutes()
|
|
if err := a.Build(); err != nil {
|
|
t.Fatalf("build: %v", err)
|
|
}
|
|
resp, err := a.Fiber().Test(httptest.NewRequest("GET", path, nil))
|
|
if err != nil {
|
|
t.Fatalf("GET %s: %v", path, err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// rel returns the target of one link relation, or "" when the answer carries none.
|
|
func rel(resp *http.Response, name string) string {
|
|
for _, l := range resp.Header.Values("Link") {
|
|
if !strings.Contains(l, `rel="`+name+`"`) {
|
|
continue
|
|
}
|
|
if i, j := strings.Index(l, "<"), strings.Index(l, ">"); i == 0 && j > 0 {
|
|
return l[1:j]
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// THE PROPERTY: a link never names an address this app does not serve.
|
|
//
|
|
// It is the whole reason the header is derived from the same map that installs
|
|
// the route rather than written beside it. A description advertised at an
|
|
// address that 404s is worse than none — a generic client follows it, gets
|
|
// nothing, and reports the API as broken rather than as undescribed.
|
|
func TestLink_NamesOnlyAddressesThisAppServes(t *testing.T) {
|
|
a := quiet("served")
|
|
a.Get("/thing", func(ctx context.Context, _ *linkIn) (*linkOut, error) {
|
|
return &linkOut{Name: "thing"}, nil
|
|
})
|
|
a.installOpenAPIRoutes()
|
|
if err := a.Build(); err != nil {
|
|
t.Fatalf("build: %v", err)
|
|
}
|
|
|
|
resp, err := a.Fiber().Test(httptest.NewRequest("GET", "/thing", nil))
|
|
if err != nil {
|
|
t.Fatalf("GET /thing: %v", err)
|
|
}
|
|
targets := map[string]string{
|
|
relDesc: rel(resp, relDesc),
|
|
relDoc: rel(resp, relDoc),
|
|
}
|
|
for name, at := range targets {
|
|
if at == "" {
|
|
t.Fatalf("no %s link on the answer", name)
|
|
}
|
|
// FOLLOW it, which is the only check that can fail for the right reason.
|
|
got, err := a.Fiber().Test(httptest.NewRequest("GET", at, nil))
|
|
if err != nil {
|
|
t.Fatalf("following %s -> %s: %v", name, at, err)
|
|
}
|
|
_, _ = io.Copy(io.Discard, got.Body)
|
|
_ = got.Body.Close()
|
|
if got.StatusCode != 200 {
|
|
t.Errorf("%s names %s, which answers %d — a link must not advertise an "+
|
|
"address this app does not serve", name, at, got.StatusCode)
|
|
}
|
|
}
|
|
}
|
|
|
|
// self is the address the caller REACHED, which is the one thing a client cannot
|
|
// reconstruct: it may have arrived through a proxy, an alias or a redirect.
|
|
func TestLink_SelfIsTheAddressReached(t *testing.T) {
|
|
resp := linked(t, "/thing")
|
|
if got := rel(resp, relSelf); got != "/thing" {
|
|
t.Errorf("self = %q, want /thing", got)
|
|
}
|
|
}
|
|
|
|
// AN APP WITH NO DESCRIPTION STILL NAMES ITSELF. Disabling the document says
|
|
// nothing about whether an answer has an address, so self stays and only the
|
|
// relations that would 404 are withheld.
|
|
func TestLink_AnAppWithNoDocumentStillNamesItself(t *testing.T) {
|
|
a := New(Config{AppName: "silent", DisableStartupMessage: true,
|
|
OpenAPI: OpenAPIConfig{Disabled: true}})
|
|
a.Get("/thing", func(ctx context.Context, _ *linkIn) (*linkOut, error) {
|
|
return &linkOut{Name: "thing"}, nil
|
|
})
|
|
a.installOpenAPIRoutes() // no-op when Disabled
|
|
if err := a.Build(); err != nil {
|
|
t.Fatalf("build: %v", err)
|
|
}
|
|
resp, err := a.Fiber().Test(httptest.NewRequest("GET", "/thing", nil))
|
|
if err != nil {
|
|
t.Fatalf("GET /thing: %v", err)
|
|
}
|
|
got := resp.Header.Values("Link")
|
|
if len(got) != 1 || !strings.Contains(got[0], `rel="self"`) {
|
|
t.Errorf("want self alone, got %v", got)
|
|
}
|
|
for _, l := range got {
|
|
if strings.Contains(l, "service-desc") || strings.Contains(l, "service-doc") {
|
|
t.Errorf("advertised a document it does not serve: %s", l)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A ROUTE IS NOT A TYPED OP, AND AN ANSWER IS STILL AN ANSWER. installOpenAPIRoutes
|
|
// returns early for an app with an empty registry, and self used to ride that
|
|
// same condition — so an app serving plain routes, which most of ours do,
|
|
// named nothing. This is the case that was silently uncovered.
|
|
func TestLink_PlainRoutesAreNamedToo(t *testing.T) {
|
|
a := New(Config{AppName: "plain", DisableStartupMessage: true})
|
|
a.Raw("GET", "/plain", func(c *Ctx) error { return c.String(200, "ok") })
|
|
a.installOpenAPIRoutes() // returns early: nothing typed to describe
|
|
if err := a.Build(); err != nil {
|
|
t.Fatalf("build: %v", err)
|
|
}
|
|
resp, err := a.Fiber().Test(httptest.NewRequest("GET", "/plain", nil))
|
|
if err != nil {
|
|
t.Fatalf("GET /plain: %v", err)
|
|
}
|
|
got := resp.Header.Values("Link")
|
|
if len(got) != 1 || !strings.Contains(got[0], `</plain>; rel="self"`) {
|
|
t.Errorf("a plain route must name its own address, got %v", got)
|
|
}
|
|
}
|
|
|
|
// Link is a LIST header (RFC 8288 §3), so a handler's own links SURVIVE. A page
|
|
// naming next and prev keeps them and gets the service relations beside them —
|
|
// setting rather than adding would silently drop the handler's own paging.
|
|
func TestLink_AHandlersOwnLinksSurvive(t *testing.T) {
|
|
a := quiet("paged")
|
|
// BEFORE the route: zip composes middleware into the routes written after it,
|
|
// so one installed below its leaves never wraps them.
|
|
a.Use(H(func(c *Ctx) error {
|
|
c.fc.Response().Header.Add("Link", `</page?after=2>; rel="next"`)
|
|
return c.Continue()
|
|
}))
|
|
a.Get("/page", func(ctx context.Context, _ *linkIn) (*linkOut, error) {
|
|
return &linkOut{Name: "page"}, nil
|
|
})
|
|
a.installOpenAPIRoutes()
|
|
if err := a.Build(); err != nil {
|
|
t.Fatalf("build: %v", err)
|
|
}
|
|
resp, err := a.Fiber().Test(httptest.NewRequest("GET", "/page", nil))
|
|
if err != nil {
|
|
t.Fatalf("GET /page: %v", err)
|
|
}
|
|
if got := rel(resp, "next"); got != "/page?after=2" {
|
|
t.Errorf("the handler's own next link = %q — the service relations replaced "+
|
|
"it instead of joining it", got)
|
|
}
|
|
if rel(resp, relDesc) == "" {
|
|
t.Error("the service description link is missing beside the handler's own")
|
|
}
|
|
}
|
|
|
|
// AN ANSWER THAT ALREADY NAMES ITSELF IS NOT NAMED AGAIN.
|
|
//
|
|
// This is the shape of a proxied answer: the far end served it and wrote its own
|
|
// links into this very response, so by the time the near side's middleware runs
|
|
// they are already here. Adding a second copy is what put three self links on one
|
|
// production answer.
|
|
func TestSelfLinkIsNotWrittenTwice(t *testing.T) {
|
|
a := New(Config{AppName: "hop", DisableStartupMessage: true})
|
|
// A handler standing in for the far end of a hop.
|
|
a.Raw("GET", "/v1/relayed", func(c *Ctx) error {
|
|
c.Fiber().Response().Header.Add("Link", `</v1/relayed>; rel="self"`)
|
|
c.Fiber().Response().Header.Add("Link", `</.well-known/openapi.json>; rel="service-desc"`)
|
|
return c.JSON(200, map[string]bool{"ok": true})
|
|
})
|
|
a.Get("/v1/own", func(_ context.Context, _ *struct{}) (*struct {
|
|
OK bool `json:"ok"`
|
|
}, error) {
|
|
return &struct {
|
|
OK bool `json:"ok"`
|
|
}{true}, nil
|
|
})
|
|
if err := a.Build(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
count := func(path, rel string) int {
|
|
res, err := a.Fiber().Test(httptest.NewRequest("GET", path, nil))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = res.Body.Close() }()
|
|
n := 0
|
|
for _, v := range res.Header.Values("Link") {
|
|
if strings.Contains(v, `rel="`+rel+`"`) {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
if n := count("/v1/relayed", "self"); n != 1 {
|
|
t.Errorf("a relayed answer carries %d self links, want 1", n)
|
|
}
|
|
if n := count("/v1/relayed", "service-desc"); n != 1 {
|
|
t.Errorf("a relayed answer carries %d service-desc links, want 1", n)
|
|
}
|
|
// And an answer this app served itself still gets them, or the guard has
|
|
// simply turned the header off.
|
|
if n := count("/v1/own", "self"); n != 1 {
|
|
t.Errorf("an answer this app served carries %d self links, want 1", n)
|
|
}
|
|
}
|