mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
v1.18.0 sealed OpTarget with an unexported method, reasoning that nothing outside
zip should claim to be somewhere an op lives. That reasoning was wrong, and
building hanzoai/cloud against it is what said so: hanzoai/commerce's
middleware.Mint decorates a zip.Router — it prepends a platform-only gate to
every route registered through it, so that money-mint routes cannot be declared
without their gate. A sealed method made Mint uncompilable, with no fix available
to it from outside this package.
Worse than uncompilable is the version that compiles: a decorator that embeds the
wrapped Router and inherits its OpScope registers a typed money-mint op with NO
GATE. The decorator's entire purpose, silently skipped, on the routes that move
money.
So the method is exported and returns a value that says what it is —
OpScope{App, Prefix, Middleware} — and its doc says what a decorator owes: fold
your middleware into s.Middleware or the op you register is ungated. A struct
rather than three returns so a later field does not break implementors.
TestOpTarget_AnOutsideDecoratorCanCarryOps is the shape, written as an outside
package would write it: embed the Router, override OpScope, assert the gate
reaches a typed op. That test is what a sealed interface could not have.
My compatibility check for v1.18.0 grepped the local source trees and not the
module cache, which is why a published implementor got through. Building the
consumer is the check that finds this; the grep is not.
Co-authored-by: Hanzo Dev <[email protected]>
215 lines
6.9 KiB
Go
215 lines
6.9 KiB
Go
package zip_test
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
// groupIn addresses one thing under a group.
|
|
type groupIn struct {
|
|
ID string `json:"id"`
|
|
Note string `json:"note"`
|
|
}
|
|
|
|
type groupOut struct {
|
|
ID string `json:"id"`
|
|
Note string `json:"note"`
|
|
Seen string `json:"seen"`
|
|
}
|
|
|
|
func echoGroup(_ context.Context, in *groupIn) (*groupOut, error) {
|
|
return &groupOut{ID: in.ID, Note: in.Note}, nil
|
|
}
|
|
|
|
func call2(t *testing.T, a *zip.App, method, path, body string) (int, string) {
|
|
t.Helper()
|
|
var r io.Reader
|
|
if body != "" {
|
|
r = strings.NewReader(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)
|
|
return resp.StatusCode, string(raw)
|
|
}
|
|
|
|
// A typed op declared on a Group carries the group's prefix — in the ROUTE it
|
|
// answers on and in the identity every projection reads. Before this, zip.Get
|
|
// took the *App only, so an app built on groups had to spell its prefix out per
|
|
// route to have typed ops at all: friction that made the untyped handler, which
|
|
// does inherit the prefix, the path of least resistance.
|
|
func TestOpTarget_GroupPrefixIsPartOfTheOp(t *testing.T) {
|
|
a := zip.New(zip.Config{AppName: "g", DisableStartupMessage: true})
|
|
v1 := a.Group("/v1")
|
|
things := v1.Group("/things")
|
|
|
|
zip.Get(things, "/:id", echoGroup)
|
|
zip.Post(v1, "/direct", echoGroup)
|
|
|
|
// The route answers under the composed prefix.
|
|
if code, body := call2(t, a, "GET", "/v1/things/abc?note=hi", ""); code != 200 ||
|
|
!strings.Contains(body, `"id":"abc"`) || !strings.Contains(body, `"note":"hi"`) {
|
|
t.Fatalf("GET /v1/things/abc = %d %s", code, body)
|
|
}
|
|
if code, _ := call2(t, a, "GET", "/things/abc", ""); code != 404 {
|
|
t.Errorf("GET /things/abc = %d, want 404 — the prefix is part of the route", code)
|
|
}
|
|
|
|
// And the identity every projection reads is the whole path.
|
|
paths := map[string]bool{}
|
|
for _, c := range a.Commands() {
|
|
paths[c.Path] = true
|
|
}
|
|
for _, want := range []string{"/v1/things/:id", "/v1/direct"} {
|
|
if !paths[want] {
|
|
t.Errorf("no op at %q; ops are %v", want, keysOf(anyMap(paths)))
|
|
}
|
|
}
|
|
spec, _ := a.OpenAPISpec()["paths"].(map[string]map[string]any)
|
|
if _, ok := spec["/v1/things/{id}"]; !ok {
|
|
t.Errorf("document has no /v1/things/{id}; paths = %v", keysOf(anyMap2(spec)))
|
|
}
|
|
}
|
|
|
|
// A typed op and an untyped one declared on the SAME group land on the same
|
|
// prefix. zip composes the typed op's path itself, so this is the test that says
|
|
// its composition is the router's composition and not a second rule beside it.
|
|
func TestOpTarget_TypedAndUntypedComposeAlike(t *testing.T) {
|
|
a := zip.New(zip.Config{AppName: "g", DisableStartupMessage: true})
|
|
g := a.Group("/v1/mix")
|
|
g.Get("/untyped", func(c *zip.Ctx) error { return c.JSON(200, map[string]string{"kind": "untyped"}) })
|
|
zip.Get(a.Group("/v1/mix"), "/typed", echoGroup)
|
|
|
|
if code, body := call2(t, a, "GET", "/v1/mix/untyped", ""); code != 200 || !strings.Contains(body, "untyped") {
|
|
t.Errorf("untyped leaf = %d %s", code, body)
|
|
}
|
|
if code, _ := call2(t, a, "GET", "/v1/mix/typed", ""); code != 200 {
|
|
t.Errorf("typed leaf = %d, want 200 at the same prefix", code)
|
|
}
|
|
}
|
|
|
|
// With() middleware composes around a typed op too. Dropping it silently would
|
|
// be a hole rather than an inconvenience: With is where the gates go.
|
|
func TestOpTarget_WithWrapsATypedOp(t *testing.T) {
|
|
a := zip.New(zip.Config{AppName: "g", DisableStartupMessage: true})
|
|
gate := func(next zip.Handler) zip.Handler {
|
|
return func(c *zip.Ctx) error {
|
|
if c.Header("X-Pass") != "yes" {
|
|
return zip.ErrForbidden("gated")
|
|
}
|
|
c.SetHeader("X-Gate", "ran")
|
|
return next(c)
|
|
}
|
|
}
|
|
zip.Get(a.With(gate), "/v1/gated/:id", echoGroup)
|
|
|
|
if code, body := call2(t, a, "GET", "/v1/gated/x", ""); code != 403 {
|
|
t.Fatalf("ungated request = %d %s, want 403 — the middleware did not run", code, body)
|
|
}
|
|
|
|
req, _ := http.NewRequest("GET", "/v1/gated/x", nil)
|
|
req.Header.Set("X-Pass", "yes")
|
|
resp, err := a.Fiber().Test(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("gated-through request = %d, want 200", resp.StatusCode)
|
|
}
|
|
if resp.Header.Get("X-Gate") != "ran" {
|
|
t.Errorf("X-Gate = %q, want the middleware's mark", resp.Header.Get("X-Gate"))
|
|
}
|
|
// It is still one op, with the whole path.
|
|
if cmds := a.Commands(); len(cmds) != 1 || cmds[0].Path != "/v1/gated/:id" {
|
|
t.Errorf("commands = %+v, want one op at /v1/gated/:id", cmds)
|
|
}
|
|
}
|
|
|
|
func anyMap(m map[string]bool) map[string]any {
|
|
out := map[string]any{}
|
|
for k := range m {
|
|
out[k] = nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
func anyMap2(m map[string]map[string]any) map[string]any {
|
|
out := map[string]any{}
|
|
for k := range m {
|
|
out[k] = nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// gateRouter is the shape a decorating Router outside this package has to take:
|
|
// it wraps another Router and overrides OpScope so the gate it installs on every
|
|
// untyped route also reaches a typed op declared through it. Getting this wrong
|
|
// is not an inconvenience — it registers an UNGATED op on a router whose whole
|
|
// purpose is the gate. hanzoai/commerce's Mint is the real one.
|
|
type gateRouter struct {
|
|
zip.Router // embedded: everything this decorator does not override
|
|
gate zip.Middleware
|
|
}
|
|
|
|
func (g gateRouter) OpScope() zip.OpScope {
|
|
s := g.Router.OpScope()
|
|
if s.Middleware == nil {
|
|
s.Middleware = g.gate
|
|
} else {
|
|
s.Middleware = zip.Chain(s.Middleware, g.gate)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// A Router implemented OUTSIDE zip can carry typed ops, and its middleware
|
|
// reaches them. OpTarget is exported for exactly this: sealing it would have
|
|
// made every decorating router in the fleet unable to compile, and the ones that
|
|
// could would have silently dropped their gate.
|
|
func TestOpTarget_AnOutsideDecoratorCanCarryOps(t *testing.T) {
|
|
a := zip.New(zip.Config{AppName: "g", DisableStartupMessage: true})
|
|
gate := func(next zip.Handler) zip.Handler {
|
|
return func(c *zip.Ctx) error {
|
|
if c.Header("X-Pass") != "yes" {
|
|
return zip.ErrForbidden("gated")
|
|
}
|
|
return next(c)
|
|
}
|
|
}
|
|
mint := gateRouter{Router: a.Group("/v1/mint"), gate: gate}
|
|
zip.Post(mint, "/deposit", echoGroup)
|
|
|
|
if cmds := a.Commands(); len(cmds) != 1 || cmds[0].Path != "/v1/mint/deposit" {
|
|
t.Fatalf("commands = %+v, want one op at /v1/mint/deposit", cmds)
|
|
}
|
|
if code, _ := call2(t, a, "POST", "/v1/mint/deposit", `{"id":"x"}`); code != 403 {
|
|
t.Fatalf("ungated = %d, want 403 — the decorator's gate did not reach the op", code)
|
|
}
|
|
req, _ := http.NewRequest("POST", "/v1/mint/deposit", strings.NewReader(`{"id":"x"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Pass", "yes")
|
|
resp, err := a.Fiber().Test(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("gated-through = %d, want 200", resp.StatusCode)
|
|
}
|
|
}
|