Every seam states the address, not just the route

v1.37.9 fixed the REST path and the three by-name seams that already looked the
op up in the composed registry. Two did not: the graph resolved its ops from
that same registry and then invoked them without saying so, and the in-process
invoke did the same. So the contract fell back to the declaration — for anything
declared on a group, the bare leaf — and a rule reading op.Path governed the
REST call while missing the graph call to the SAME operation.

A test now asks both seams about one op and requires the same answer.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-09-17 19:39:24 -07:00
co-authored by Hanzo Dev
parent 8aa1e5089f
commit c47944c0f8
3 changed files with 81 additions and 2 deletions
+75
View File
@@ -0,0 +1,75 @@
package zip_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/zap-proto/zip"
)
type probeIn struct{}
type probeOut struct {
OK bool `json:"ok"`
}
type crm struct{}
// Probe answers whether the surface is up.
func (crm) Probe(context.Context, *probeIn) (*probeOut, error) { return &probeOut{OK: true}, nil }
// EVERY SEAM STATES THE ADDRESS, not just the REST route.
//
// An op reached by NAME — through the graph, a tools/call, the call plane or an
// in-process invoke — is the same operation as the one reached by path, so the
// rule has to see the same identity either way. The graph resolved ops out of
// the composed registry and then invoked them without saying so, which left the
// contract falling back to the declaration: for anything declared on a group,
// the bare leaf. A rule reading op.Path governed the REST call and not the graph
// call to the same operation.
func TestGraph_ARuleSeesTheServedAddress(t *testing.T) {
app := zip.New(zip.Config{AppName: "crm", DisableStartupMessage: true})
app.Group("").Group("/v1/crm").Post("/probe", crm{}.Probe, zip.WithOperationID("crm_probe"))
app.MountGraph("/v1/graphql")
var seen []string
app.Authorize(func(_ context.Context, op zip.Op, _ any) (zip.Decision, error) {
seen = append(seen, op.Method+" "+op.Path+" "+op.OperationID)
return zip.Decision{Effect: zip.Allow}, nil
})
if err := app.Build(); err != nil {
t.Fatal(err)
}
// Through the graph, by name.
body, _ := json.Marshal(zip.GraphRequest{Query: `mutation { crm_probe { ok } }`})
req := httptest.NewRequest(http.MethodPost, "/v1/graphql", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
// And over REST, by path.
rr := httptest.NewRequest(http.MethodPost, "/v1/crm/probe", bytes.NewReader([]byte(`{}`)))
rr.Header.Set("Content-Type", "application/json")
resp2, err := app.Test(rr)
if err != nil {
t.Fatal(err)
}
_ = resp2.Body.Close()
if len(seen) != 2 {
t.Fatalf("authorizer ran %d times, want 2 (graph and REST): %v", len(seen), seen)
}
const want = "POST /v1/crm/probe crm_probe"
for i, got := range seen {
if got != want {
t.Errorf("call %d saw %q, want %q — both seams answer for one operation", i, got, want)
}
}
}
+1 -1
View File
@@ -89,7 +89,7 @@ func Here[In, Out any](ctx context.Context, a *App, op string, in *In) (*Out, er
if o == nil || o.direct == nil { if o == nil || o.direct == nil {
return nil, ErrNotFound("unknown op: " + op) return nil, ErrNotFound("unknown op: " + op)
} }
out, err := o.direct(ctx, in) out, err := o.direct(withOp(ctx, servedOp(o)), in)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+5 -1
View File
@@ -249,7 +249,11 @@ func (e *graph) call(ctx context.Context, op *registeredOp, s *gqlSel, path []an
// seam binds them: the header is the authority for the field it names. // seam binds them: the header is the authority for the field it names.
bindHeaders(in, headerOf(ctx)) bindHeaders(in, headerOf(ctx))
} }
out, err := op.direct(ctx, in) // The op came out of the composed registry, so it knows the address it is
// served at. Saying so is what lets a path-keyed rule govern a graph field
// the same way it governs the REST route: without it the contract fell back
// to the declaration, which for anything declared on a group is the leaf.
out, err := op.direct(withOp(ctx, servedOp(op)), in)
if err != nil { if err != nil {
e.fail(path, err.Error()) e.fail(path, err.Error())
return nil return nil