mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
a group has no name, so it must not answer for who wrote the op
Graft qualifies a composed type by the app that DECLARED it — iam.Application, iam.Role — because two children may honestly both call a type Application and a document that let the second overwrite the first publishes one name for two shapes. Every generated SDK then binds whichever the merge read last. composeOps asked the ENCLOSING definition for that name. App.Group returns an App with its name stripped (groupConfig), and every real app declares its routes in groups, so the question went to a nameless thing and the answer was nobody. One level with no group still worked, which is why nothing said so: hanzoai/iam publishes 345 schemas through groups and every one of them arrived in cloud's fleet document unqualified, colliding with the host's own Application and Role. The author is the nearest NAMED ancestor, carried down the walk as scope.origin — computed once at the inclusion site, inherited by every group beneath it, empty at the root so a program that composes nothing is byte-identical to what it published before Origin existed. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -130,7 +130,9 @@ func composeOps(occ []occurrence) []*registeredOp {
|
||||
// Who DECLARED the type — a property of the code, never of where it
|
||||
// was deployed. An op that already names its declarer keeps that
|
||||
// name, so a definition two levels deep still credits its author.
|
||||
c.Origin = o.ctx.in.cfg.AppName
|
||||
// [scope.origin] is what makes that last clause true: the enclosing
|
||||
// definition is a GROUP for every real app, and a group has no name.
|
||||
c.Origin = o.ctx.origin
|
||||
}
|
||||
if len(c.Tags) == 0 && c.Origin != "" {
|
||||
c.Tags = []string{c.Origin}
|
||||
|
||||
@@ -351,6 +351,42 @@ func TestGraft_UngraftedDocumentIsUnchanged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGraft_AGroupDoesNotEraseTheAuthor is the qualification rule at the only
|
||||
// depth real apps use. A child declares its routes in groups — that is what a
|
||||
// prefix IS here — and a group is an App with its name stripped
|
||||
// (App.groupConfig), so reading the ENCLOSING definition asked a nameless thing
|
||||
// who wrote the op and got "nobody".
|
||||
//
|
||||
// Nothing said so, because the ungrafted and one-level cases both still passed.
|
||||
// hanzoai/iam publishes 345 schemas through groups: every one of them arrived
|
||||
// as a bare Application, Role, Input — colliding with the host's own types of
|
||||
// those names, one name for two shapes, in the document every SDK is generated
|
||||
// from.
|
||||
func TestGraft_AGroupDoesNotEraseTheAuthor(t *testing.T) {
|
||||
child := zip.New(zip.Config{AppName: "iam", DisableStartupMessage: true})
|
||||
g := child.Group("/v1/iam")
|
||||
zip.Post(g, "/users", func(_ context.Context, in *userIn) (*userOut, error) { return &userOut{}, nil })
|
||||
// Two levels down: a group inside a group still credits the app.
|
||||
zip.Post(g.Group("/deep"), "/x", func(_ context.Context, in *userIn) (*userOut, error) { return &userOut{}, nil })
|
||||
|
||||
host := zip.New(zip.Config{AppName: "cloud", DisableStartupMessage: true})
|
||||
host.Use(child)
|
||||
if err := host.Build(); err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
schemas := host.OpenAPISpec()["components"].(map[string]any)["schemas"].(map[string]any)
|
||||
for _, want := range []string{"iam.userIn", "iam.userOut"} {
|
||||
if _, ok := schemas[want]; !ok {
|
||||
t.Errorf("no %q — a type declared inside a grafted app's group lost its author: %v", want, keysOf(schemas))
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"userIn", "userOut"} {
|
||||
if _, ok := schemas[unwanted]; ok {
|
||||
t.Errorf("%q is unqualified — it would bind to whichever app the merge read last", unwanted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGraft_ChildControlPlaneIsNotAdopted: the parent keeps its own /docs, MCP
|
||||
// door, op plane and declaration. A child that took them would take the whole
|
||||
// composition's document.
|
||||
|
||||
@@ -119,6 +119,18 @@ type scope struct {
|
||||
trail *trail
|
||||
// depth is the number of inclusions between the root and here.
|
||||
depth int
|
||||
// origin is WHO DECLARED what is visited here: the nearest ancestor that
|
||||
// has a name. Empty at the root, so the served app's own ops are exactly
|
||||
// what they were before composition existed.
|
||||
//
|
||||
// It is the nearest NAMED ancestor and not the enclosing definition,
|
||||
// because [App.Group] is an App with its name stripped ([App.groupConfig]).
|
||||
// Reading the enclosing definition credited a group with declaring what its
|
||||
// app declared — and a group has no name, so the answer was "nobody". A
|
||||
// grafted app that declares its routes in groups (every real one does)
|
||||
// published every type UNQUALIFIED: hanzoai/iam's 345 schemas arrived as
|
||||
// bare Application and Role, colliding with the host's own.
|
||||
origin string
|
||||
}
|
||||
|
||||
// mwStack is a persistent middleware stack, innermost cell first, shared
|
||||
@@ -260,26 +272,34 @@ func descend(a *App, sc scope, ancestors []*App, out *[]occurrence) error {
|
||||
switch n := e.n.(type) {
|
||||
case Handler:
|
||||
mw = mw.push(n, e.site, sc.depth)
|
||||
*out = append(*out, occurrence{n: n, site: e.site,
|
||||
ctx: scope{in: a, prefix: sc.prefix, mw: mw, trail: sc.trail, depth: sc.depth}})
|
||||
here := sc
|
||||
here.in, here.mw = a, mw
|
||||
*out = append(*out, occurrence{n: n, site: e.site, ctx: here})
|
||||
|
||||
case route:
|
||||
*out = append(*out, occurrence{n: n, site: e.site,
|
||||
ctx: scope{in: a, prefix: sc.prefix, mw: mw, trail: sc.trail, depth: sc.depth}})
|
||||
here := sc
|
||||
here.in, here.mw = a, mw
|
||||
*out = append(*out, occurrence{n: n, site: e.site, ctx: here})
|
||||
|
||||
case *App:
|
||||
// The included app's environment is anchored HERE. Everything the
|
||||
// parent appends after this line is invisible to it, and everything
|
||||
// the child appends later still lands inside this anchor.
|
||||
origin := sc.origin
|
||||
if n.cfg.AppName != "" {
|
||||
origin = n.cfg.AppName
|
||||
}
|
||||
inner := scope{
|
||||
in: n,
|
||||
prefix: sc.prefix + n.prefix,
|
||||
mw: mw,
|
||||
trail: sc.trail.push(n, n.label(), e.site),
|
||||
depth: sc.depth + 1,
|
||||
origin: origin,
|
||||
}
|
||||
*out = append(*out, occurrence{n: n, site: e.site,
|
||||
ctx: scope{in: a, prefix: inner.prefix, mw: mw, trail: inner.trail, depth: inner.depth}})
|
||||
at := inner
|
||||
at.in, at.mw = a, mw
|
||||
*out = append(*out, occurrence{n: n, site: e.site, ctx: at})
|
||||
if err := descend(n, inner, ancestors, out); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user