mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
A relay is told which resource, not just which operation
The pattern says which operation answered; the address says which resource it was asked about. A handler that forwards needs both, and could only get the second by being wrapped in a closure that held the decoded input — which is what put a service's whole surface behind a helper this pass cannot read. The one seam every projection funnels through holds the resolved pattern and the decoded input together, so the address is stated once and correctly for REST, a tools/call, the call plane and an in-process invoke alike, where three of those have no request to read a path from. It is computed on demand: an op whose pattern carries no parameter pays a string compare, one that does pays reflection, and only if something asks. Deliberately not a field on Op. Op is what a rule is handed and what the result hook is told, and both are narrow on purpose — the hook is given the operation and the outcome, never the input. A concrete address carries path parameters, which are input, so putting it there would widen two contracts to serve a third. A test pins what those two see, including that it is still the declared leaf rather than the served pattern: that gap is older than this change and is its own decision to make. No additional allocation over the previous patch, measured on Benchmark_TypedRoute: 63 either way. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
package zip_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
type traceIn struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type seen struct {
|
||||
Pattern string `json:"pattern"`
|
||||
Address string `json:"address"`
|
||||
HasAddr bool `json:"hasAddr"`
|
||||
}
|
||||
|
||||
type tracer struct{}
|
||||
|
||||
// Spans returns the spans of one trace.
|
||||
func (tracer) Spans(ctx context.Context, _ *traceIn) (*seen, error) {
|
||||
op, _ := zip.OpOf(ctx)
|
||||
addr, ok := zip.AddressOf(ctx)
|
||||
return &seen{Pattern: op.Path, Address: addr, HasAddr: ok}, nil
|
||||
}
|
||||
|
||||
// A RELAY NEEDS BOTH: the pattern says which operation, the address says which
|
||||
// resource. A handler that forwards has to name both, and before this it could
|
||||
// only get the second by being wrapped in a closure that held the decoded input
|
||||
// — which is what made a whole service's registrations invisible to cmd/zipdoc.
|
||||
func TestAddressOf_IsThisCallsConcreteAddress(t *testing.T) {
|
||||
app := zip.New(zip.Config{AppName: "traces", DisableStartupMessage: true})
|
||||
app.Group("/v1/o11y").Get("/traces/:id", tracer{}.Spans)
|
||||
|
||||
resp, err := app.Test(httptest.NewRequest("GET", "/v1/o11y/traces/abc123", nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
var got seen
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if want := "/v1/o11y/traces/:id"; got.Pattern != want {
|
||||
t.Errorf("pattern = %q, want %q", got.Pattern, want)
|
||||
}
|
||||
if !got.HasAddr {
|
||||
t.Fatal("no address; a handler inside its registration always has one")
|
||||
}
|
||||
if want := "/v1/o11y/traces/abc123"; got.Address != want {
|
||||
t.Errorf("address = %q, want %q — the parameter filled from the input", got.Address, want)
|
||||
}
|
||||
}
|
||||
|
||||
// THE CONCRETE ADDRESS IS NOT ON Op, deliberately.
|
||||
//
|
||||
// Op is what the authorizer is handed and what OnResult is told, and both are
|
||||
// narrow on purpose — the result hook is given the operation and the outcome and
|
||||
// never the input. A concrete address carries path parameters, which ARE input,
|
||||
// so putting it on Op would widen two contracts as a side effect of serving a
|
||||
// third. This is the test that says so, because a later reader would otherwise
|
||||
// fix the "inconsistency" by helpfully resolving Op.Path.
|
||||
//
|
||||
// It also pins what those two are handed TODAY, which is worth knowing: the
|
||||
// DECLARED leaf, not the address the call is served at. So Op.Path means the
|
||||
// declaration to a rule and the served pattern to [zip.OpOf], and a rule cannot
|
||||
// tell two mountings of one definition apart. That is a real limit and it is
|
||||
// older than this test; changing what authorization sees is its own decision,
|
||||
// not a side effect of giving a handler its address.
|
||||
func TestAddressOf_WhatARuleIsHandedIsUnchanged(t *testing.T) {
|
||||
app := zip.New(zip.Config{AppName: "traces", DisableStartupMessage: true})
|
||||
app.Group("/v1/o11y").Get("/traces/:id", tracer{}.Spans)
|
||||
|
||||
var asked []string
|
||||
var toldPath string
|
||||
app.Authorize(func(_ context.Context, op zip.Op, _ any) (zip.Decision, error) {
|
||||
asked = append(asked, op.Path)
|
||||
return zip.Decision{Effect: zip.Allow}, nil
|
||||
})
|
||||
app.OnResult(func(_ context.Context, op zip.Op, _ error) { toldPath = op.Path })
|
||||
if err := app.Build(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := app.Test(httptest.NewRequest("GET", "/v1/o11y/traces/abc123", nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// The declaration, unresolved — and no concrete address anywhere near it.
|
||||
if len(asked) != 1 || asked[0] != "/traces/:id" {
|
||||
t.Errorf("authorizer saw %v, want the declared leaf", asked)
|
||||
}
|
||||
if toldPath != "/traces/:id" {
|
||||
t.Errorf("result hook was told %q, want the declared leaf", toldPath)
|
||||
}
|
||||
for _, p := range append(asked, toldPath) {
|
||||
if p == "/v1/o11y/traces/abc123" {
|
||||
t.Errorf("a rule was handed the concrete address %q; path parameters are input", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An op with no parameter has an address equal to its pattern, and pays no
|
||||
// reflection to find that out.
|
||||
func TestAddressOf_NoParameterIsThePatternItself(t *testing.T) {
|
||||
app := zip.New(zip.Config{AppName: "traces", DisableStartupMessage: true})
|
||||
app.Group("/v1/o11y").Get("/stats", tracer{}.Spans)
|
||||
|
||||
resp, err := app.Test(httptest.NewRequest("GET", "/v1/o11y/stats", nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
var got seen
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Address != "/v1/o11y/stats" || got.Pattern != got.Address {
|
||||
t.Errorf("address = %q, pattern = %q; both are the same with no parameter", got.Address, got.Pattern)
|
||||
}
|
||||
}
|
||||
@@ -2,56 +2,88 @@ package zip
|
||||
|
||||
import "context"
|
||||
|
||||
// opKey carries the operation being served. Its own type, so nothing else can
|
||||
// collide with it or read it by guessing a string.
|
||||
// opKey carries the invocation's operation. Its own type, so nothing else can collide
|
||||
// with it or read it by guessing a string.
|
||||
type opKey struct{}
|
||||
|
||||
// invocation is what a handler may ask about the invocation it is inside: which
|
||||
// operation, and the address this particular call was made to.
|
||||
//
|
||||
// The address is a FUNCTION because computing it reads the decoded input, and
|
||||
// most ops never ask. An op whose pattern carries no parameter pays a string
|
||||
// compare; one that does pays reflection, and only if something reads it.
|
||||
type invocation struct {
|
||||
op Op
|
||||
addr func() string
|
||||
}
|
||||
|
||||
// OpOf is the operation whose handler is running, and whether there is one.
|
||||
//
|
||||
// It answers the question a handler cannot answer for itself: WHERE it is being
|
||||
// asked from. The path is the address the op is served at — a group's prefix
|
||||
// composed with the leaf, resolved for the occurrence answering this call, which
|
||||
// is not knowable at registration. A definition composed under two hosts has two
|
||||
// addresses and one handler, so the address is a property of the CALL.
|
||||
// The path is the PATTERN the operation is served at — a group's prefix composed
|
||||
// with the leaf, resolved for the occurrence answering this call, which is not
|
||||
// knowable at registration. A definition composed under two hosts has two
|
||||
// addresses and one handler, so it is a property of the CALL.
|
||||
//
|
||||
// That is why this is a context value rather than something a group could hand
|
||||
// out at declaration: a prefix read at registration is the local join, and a
|
||||
// consumer that built an address from it published one that nothing served the
|
||||
// moment its definition was composed twice.
|
||||
// That is why this is a context value rather than something a group hands out at
|
||||
// declaration: a prefix read at registration is the local join, and an address
|
||||
// built from it named something nothing served the moment the definition was
|
||||
// composed twice.
|
||||
//
|
||||
// There is no operation exactly when a handler is called directly rather than
|
||||
// through the declaration that registered it — in a unit test, say — which is
|
||||
// worth naming rather than papering over with a guessed address.
|
||||
//
|
||||
// func (a *API) Livetail(ctx context.Context, in *Tail) (*Stream, error) {
|
||||
// op, ok := zip.OpOf(ctx)
|
||||
// if !ok {
|
||||
// return nil, fmt.Errorf("livetail: called outside its registration")
|
||||
// }
|
||||
// return a.relay(ctx, op.Path, in)
|
||||
// }
|
||||
func OpOf(ctx context.Context) (Op, bool) {
|
||||
op, ok := ctx.Value(opKey{}).(Op)
|
||||
return op, ok
|
||||
}
|
||||
|
||||
// withOp states the operation being served. The seam that KNOWS the occurrence
|
||||
// calls it — the REST route, which matched, and the by-name seams, which looked
|
||||
// the op up in the composed registry — so the address in the context is the
|
||||
// resolved one.
|
||||
func withOp(ctx context.Context, op Op) context.Context {
|
||||
return context.WithValue(ctx, opKey{}, op)
|
||||
}
|
||||
|
||||
// declaredOp states the operation as DECLARED, and only if no seam has stated a
|
||||
// resolved one. It is the floor under [OpOf]: every projection funnels through
|
||||
// the one contract, so a handler always has an operation to read, and a seam
|
||||
// that can do better than the declaration overrides this by arriving first.
|
||||
func declaredOp(ctx context.Context, op Op) context.Context {
|
||||
if _, ok := ctx.Value(opKey{}).(Op); ok {
|
||||
return ctx
|
||||
c, ok := ctx.Value(opKey{}).(*invocation)
|
||||
if !ok {
|
||||
return Op{}, false
|
||||
}
|
||||
return withOp(ctx, op)
|
||||
return c.op, true
|
||||
}
|
||||
|
||||
// AddressOf is the address THIS call was made to, with the pattern's parameters
|
||||
// filled in from the input — "/v1/traces/:id" answered as "/v1/traces/abc".
|
||||
//
|
||||
// It is what a handler that FORWARDS needs: the pattern says which operation,
|
||||
// the address says which resource, and a relay has to name both. It is computed
|
||||
// from the same values [Address] takes, at the one seam that holds them both, so
|
||||
// every projection gets the same answer — including a tools/call or an
|
||||
// in-process invoke, where there is no request to read a path from.
|
||||
//
|
||||
// It is deliberately NOT a field on [Op]. Op is what the [Authorizer] is handed
|
||||
// and what [App.OnResult] is told, and both are narrow on purpose: the result
|
||||
// hook is given the operation and the outcome and never the input. A concrete
|
||||
// address carries path parameters, which are input, so putting it there would
|
||||
// widen two contracts as a side effect of serving a third. A handler asks for
|
||||
// it; a recorder is not handed it.
|
||||
func AddressOf(ctx context.Context) (string, bool) {
|
||||
c, ok := ctx.Value(opKey{}).(*invocation)
|
||||
if !ok || c.addr == nil {
|
||||
return "", false
|
||||
}
|
||||
return c.addr(), true
|
||||
}
|
||||
|
||||
// withOp states the operation being served, before the input is decoded. The
|
||||
// seam that KNOWS the occurrence calls it — the REST route, which matched, and
|
||||
// the by-name seams, which looked the op up in the composed registry — so the
|
||||
// pattern in the context is the resolved one.
|
||||
func withOp(ctx context.Context, op Op) context.Context {
|
||||
return context.WithValue(ctx, opKey{}, &invocation{op: op})
|
||||
}
|
||||
|
||||
// withAddress restates the call once the input is known, so [AddressOf] can
|
||||
// answer. It keeps whatever pattern a seam already resolved and falls back to
|
||||
// the declaration when none did.
|
||||
func withAddress[In any](ctx context.Context, declared Op, in *In) context.Context {
|
||||
op := declared
|
||||
if c, ok := ctx.Value(opKey{}).(*invocation); ok {
|
||||
op = c.op
|
||||
}
|
||||
pattern := op.Path
|
||||
return context.WithValue(ctx, opKey{}, &invocation{
|
||||
op: op,
|
||||
addr: func() string { return Address(pattern, in) },
|
||||
})
|
||||
}
|
||||
|
||||
// servedOp is the identity of an op taken from the COMPOSED registry, where the
|
||||
|
||||
@@ -646,11 +646,15 @@ func registerTyped[In, Out any](depth int, on *App, method, path string, fn Type
|
||||
// here, so no way of reaching this op can skip a check another way makes. A
|
||||
// nil *Out becomes a nil `any`.
|
||||
run := func(ctx context.Context, in *In) (out any, err error) {
|
||||
// The operation, readable by the handler through [OpOf]. Stated here
|
||||
// because this is the one contract every projection funnels through, and
|
||||
// only as the DECLARATION: a seam that matched an occurrence has already
|
||||
// stated the resolved address and keeps it.
|
||||
ctx = declaredOp(ctx, meta)
|
||||
// The call, readable by the handler through [OpOf] and [AddressOf].
|
||||
// Stated here because this is the one contract every projection funnels
|
||||
// through and the one place that holds BOTH the resolved pattern and the
|
||||
// decoded input — so the address is computed once, correctly, for REST, a
|
||||
// tools/call, the call plane and an in-process invoke alike.
|
||||
//
|
||||
// The authorizer below and the result hook are handed [Op] as an argument
|
||||
// and are unaffected: neither is given the address.
|
||||
ctx = withAddress(ctx, meta, in)
|
||||
// TOLD ONCE, ON EVERY WAY OUT. This is the one contract every projection
|
||||
// funnels through — REST, an MCP tools/call, the call plane, the graph and
|
||||
// an in-process invoke — so a hook here is told about all of them, and a
|
||||
|
||||
Reference in New Issue
Block a user