mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
A refusal travels up, instead of being written as an empty struct
This projection promises that an op whose value cannot cross gets no
method, because a client that compiles and lies is worse than one that
says the operation is out of reach. Three ways it broke that promise,
all found generating clients for node's ninety-four ops:
A field whose own type had no wire form was replaced by struct{} and the
op kept its method. The call succeeded and the payload was gone. node's
admin, info and platformvm all had []struct{} where a value should be.
A field that cannot cross now refuses the type holding it, which refuses
the op, reported once at the field.
On an EMBEDDED field that substitute is not Go — an embedded field must
be a name — so the package did not parse, SDK returned an error, and the
service got no client at all. One op of xvm's ten has that shape.
And the memo that remembers a refused type answered "no" and said
nothing, so the SECOND op reaching it had no method and no gap; the only
way to notice was to count. platformvm's get_tx_rewards vanished exactly
that way. The refusal is remembered with its reason and reported again
against each op that meets it.
Two causes join the vocabulary: nested, and embedded.
Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -79,7 +79,12 @@ func (a *App) SDK(pkg string) (*SDK, error) {
|
||||
if !validIdent(pkg) {
|
||||
return nil, fmt.Errorf("zip: %q is not a Go package name", pkg)
|
||||
}
|
||||
g := &render{sdk: &SDK{Package: pkg}, named: map[reflect.Type]string{}, taken: map[string]bool{}}
|
||||
g := &render{
|
||||
sdk: &SDK{Package: pkg},
|
||||
named: map[reflect.Type]string{},
|
||||
why: map[reflect.Type]Gap{},
|
||||
taken: map[string]bool{},
|
||||
}
|
||||
|
||||
ops := append([]*registeredOp(nil), a.Registry()...)
|
||||
sort.Slice(ops, func(i, j int) bool { return opName(ops[i]) < opName(ops[j]) })
|
||||
@@ -116,7 +121,12 @@ type call struct {
|
||||
type render struct {
|
||||
sdk *SDK
|
||||
named map[reflect.Type]string // type → the name declared for it, "" while claiming
|
||||
taken map[string]bool // every name claimed at package scope
|
||||
// why is the refusal remembered for a type that has none. Without it the
|
||||
// SECOND op reaching a refused type is dropped in silence: the memo below
|
||||
// answers "no" and says nothing, so the op has no method and no gap, and
|
||||
// the only way to notice is to count the methods.
|
||||
why map[reflect.Type]Gap
|
||||
taken map[string]bool // every name claimed at package scope
|
||||
calls []call
|
||||
decls []string
|
||||
}
|
||||
@@ -155,13 +165,17 @@ func (g *render) declare(t reflect.Type, op string, fields map[string]string) (s
|
||||
return "", true
|
||||
}
|
||||
if name, seen := g.named[t]; seen {
|
||||
return name, name != ""
|
||||
if name == "" {
|
||||
// Refused before, by another op. Say so again, against this one.
|
||||
had := g.why[t]
|
||||
g.gap(op, had.Field, had.Go, had.Cause)
|
||||
return "", false
|
||||
}
|
||||
return name, true
|
||||
}
|
||||
shape, err := zapenc.LayoutOf(t)
|
||||
if err != nil {
|
||||
g.named[t] = ""
|
||||
g.gap(op, goName(t), t.String(), causeOf(err))
|
||||
return "", false
|
||||
return "", g.refuse(t, op, goName(t), t.String(), causeOf(err))
|
||||
}
|
||||
// A layout that is right is not the same as a value that crosses. A fixed
|
||||
// array — every id in the fleet — has an offset and a width, so the schema
|
||||
@@ -177,9 +191,7 @@ func (g *render) declare(t reflect.Type, op string, fields map[string]string) (s
|
||||
// whole answer. It is the same fact [Schema.Coded] reports.
|
||||
for _, s := range shape.Slots {
|
||||
if strings.HasPrefix(s.Type, "bytes_fixed[") || strings.HasPrefix(s.Elem, "bytes_fixed[") {
|
||||
g.named[t] = ""
|
||||
g.gap(op, goName(t)+"."+s.Name, s.Type, causeCodec)
|
||||
return "", false
|
||||
return "", g.refuse(t, op, goName(t)+"."+s.Name, s.Type, causeCodec)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,10 +225,24 @@ func (g *render) declare(t reflect.Type, op string, fields map[string]string) (s
|
||||
if d := fields[t.Name()+"."+jsonFieldName(f)]; d != "" {
|
||||
prose(&b, "", d)
|
||||
}
|
||||
goType := g.typeOf(f.Type, op, t.Name()+"."+f.Name, fields)
|
||||
at := t.Name() + "." + f.Name
|
||||
goType, ok := g.typeOf(f.Type, op, at, fields)
|
||||
if !ok {
|
||||
// A field that cannot cross refuses the type that holds it, which
|
||||
// refuses the op. Substituting an empty struct here instead — which
|
||||
// is what this did — emitted a client that compiles, calls, and
|
||||
// arrives with the value gone. Reported once, at the field.
|
||||
return "", g.refuse(t, op, at, f.Type.String(), causeNested)
|
||||
}
|
||||
if f.Anonymous {
|
||||
// An embedded field IS its type's name, and the wire cares: it takes
|
||||
// one nested slot, where the flattened form would take several.
|
||||
// one nested slot, where the flattened form would take several. A
|
||||
// spelling that is not a name — the empty struct an unnameable type
|
||||
// renders as — is not Go there, so the type is refused rather than
|
||||
// written out unparseable.
|
||||
if !isIdent(goType) {
|
||||
return "", g.refuse(t, op, at, f.Type.String(), causeEmbedded)
|
||||
}
|
||||
fmt.Fprintf(&b, "\t%s %s\n", goType, tagOf(f))
|
||||
continue
|
||||
}
|
||||
@@ -230,36 +256,65 @@ func (g *render) declare(t reflect.Type, op string, fields map[string]string) (s
|
||||
// typeOf spells one field's Go type in the generated package: the same type it
|
||||
// has here, with any struct it reaches renamed to the declaration this package
|
||||
// makes for it.
|
||||
func (g *render) typeOf(t reflect.Type, op, at string, fields map[string]string) string {
|
||||
func (g *render) typeOf(t reflect.Type, op, at string, fields map[string]string) (string, bool) {
|
||||
switch t.Kind() {
|
||||
case reflect.Pointer:
|
||||
return "*" + g.typeOf(t.Elem(), op, at, fields)
|
||||
elem, ok := g.typeOf(t.Elem(), op, at, fields)
|
||||
return "*" + elem, ok
|
||||
case reflect.Slice:
|
||||
return "[]" + g.typeOf(t.Elem(), op, at, fields)
|
||||
elem, ok := g.typeOf(t.Elem(), op, at, fields)
|
||||
return "[]" + elem, ok
|
||||
case reflect.Array:
|
||||
return "[" + strconv.Itoa(t.Len()) + "]" + g.typeOf(t.Elem(), op, at, fields)
|
||||
elem, ok := g.typeOf(t.Elem(), op, at, fields)
|
||||
return "[" + strconv.Itoa(t.Len()) + "]" + elem, ok
|
||||
case reflect.Map:
|
||||
// A map has no layout: a key set is not a set of offsets. LayoutOf has
|
||||
// already refused the struct that holds it, so this only renders where
|
||||
// the map sits behind something the wire never reaches.
|
||||
return "map[" + g.typeOf(t.Key(), op, at, fields) + "]" + g.typeOf(t.Elem(), op, at, fields)
|
||||
key, kok := g.typeOf(t.Key(), op, at, fields)
|
||||
elem, eok := g.typeOf(t.Elem(), op, at, fields)
|
||||
return "map[" + key + "]" + elem, kok && eok
|
||||
case reflect.Struct:
|
||||
if t.NumField() == 0 {
|
||||
return "struct{}"
|
||||
return "struct{}", true
|
||||
}
|
||||
name, ok := g.declare(t, op, fields)
|
||||
if !ok {
|
||||
return "struct{}"
|
||||
}
|
||||
return name
|
||||
return g.declare(t, op, fields)
|
||||
case reflect.Interface:
|
||||
// An interface field names no type, so there is nothing to declare and
|
||||
// nothing that could cross. It refuses the type that holds it.
|
||||
g.gap(op, at, t.String(), CauseAny)
|
||||
return "any"
|
||||
return "", false
|
||||
}
|
||||
// A basic kind keeps its own spelling — including a named one's underlying
|
||||
// type, because the name belongs to the service's package and the width is
|
||||
// what the wire reads.
|
||||
return t.Kind().String()
|
||||
return t.Kind().String(), true
|
||||
}
|
||||
|
||||
// refuse records why a type has no wire form, remembers it for the next op to
|
||||
// reach the same type, and reports false so the caller can stop.
|
||||
func (g *render) refuse(t reflect.Type, op, field, goType, cause string) bool {
|
||||
g.named[t] = ""
|
||||
g.why[t] = Gap{Field: field, Go: goType, Cause: cause}
|
||||
g.gap(op, field, goType, cause)
|
||||
return false
|
||||
}
|
||||
|
||||
// isIdent says whether a spelling can stand where Go wants a type name, which
|
||||
// is the only thing an embedded field may be.
|
||||
func isIdent(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for i, r := range s {
|
||||
switch {
|
||||
case r == '_' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z'):
|
||||
case i > 0 && r >= '0' && r <= '9':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// claim reserves one package-scope name, so a type called Client or Dial cannot
|
||||
@@ -461,6 +516,13 @@ func validIdent(s string) bool {
|
||||
const (
|
||||
causeUnnamed = "no name" // an op whose id spells no Go identifier
|
||||
causeCodec = "no codec" // a fixed array: the layout is right, reflection refuses it
|
||||
// causeNested is a field whose own type has no wire form. The type holding
|
||||
// it has none either: a struct is its fields, and one that cannot cross
|
||||
// cannot be quietly left out of a value the caller thinks it sent.
|
||||
causeNested = "nested"
|
||||
// causeEmbedded is an embedded field whose type this package cannot name.
|
||||
// Go requires a name there, so there is nothing to write that parses.
|
||||
causeEmbedded = "embedded"
|
||||
)
|
||||
|
||||
var goReserved = map[string]bool{
|
||||
|
||||
+105
@@ -485,3 +485,108 @@ func TestSDK_OneOperationOneMethodName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An id is a fixed array, which the layout states and reflection refuses. When
|
||||
// one sits on an EMBEDDED field the substitute this used to write —
|
||||
// "struct{} `json:\"...\"`" — is not Go: an embedded field must be a name. The
|
||||
// package did not parse, App.SDK returned an error, and the app got no client
|
||||
// at all. Found generating node's xvm service, where one op of ten has it.
|
||||
func TestSDK_AnEmbeddedUnnameableTypeIsAGapAndNotASyntaxError(t *testing.T) {
|
||||
// Exported, because an embedded unexported type is on no wire at all: the
|
||||
// renderer and the layout both walk exported fields only, and skip it
|
||||
// together. node's is FormattedAssetID.
|
||||
type AssetID struct {
|
||||
ID [32]byte `json:"id"`
|
||||
}
|
||||
type asset struct {
|
||||
AssetID `json:"assetID"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
app := zip.New(zip.Config{AppName: "x", DisableStartupMessage: true})
|
||||
zip.Get(app, "/v1/asset", func(_ context.Context, in *asset) (*asset, error) { return in, nil },
|
||||
zip.WithOperationID("x_asset"))
|
||||
zip.Get(app, "/v1/height", func(_ context.Context, in *Height) (*Height, error) { return in, nil },
|
||||
zip.WithOperationID("x_height"))
|
||||
|
||||
sdk, err := app.SDK("x")
|
||||
if err != nil {
|
||||
t.Fatalf("SDK: %v", err)
|
||||
}
|
||||
if strings.Contains(string(sdk.Source), "struct{} `json") {
|
||||
t.Errorf("an embedded field was written as a type with no name:\n%s", sdk.Source)
|
||||
}
|
||||
if !strings.Contains(string(sdk.Source), "func (c *Client) XHeight") {
|
||||
t.Error("the op that CAN cross lost its method to the one that cannot")
|
||||
}
|
||||
if strings.Contains(string(sdk.Source), "XAsset") {
|
||||
t.Error("the op the wire refuses got a method")
|
||||
}
|
||||
if len(sdk.Gaps) == 0 {
|
||||
t.Fatal("no gap reported for the refused op")
|
||||
}
|
||||
}
|
||||
|
||||
// A field whose own type cannot cross used to be replaced by an empty struct,
|
||||
// and the op kept its method: the call succeeded and the value was gone.
|
||||
// That is the "compiles and lies" outcome this projection exists to prevent,
|
||||
// and it reached real clients — node's admin, info and platformvm all had
|
||||
// []struct{} where a payload should be.
|
||||
func TestSDK_ANestedRefusalRefusesTheOp(t *testing.T) {
|
||||
type inner struct {
|
||||
ID [32]byte `json:"id"`
|
||||
}
|
||||
type outer struct {
|
||||
Name string `json:"name"`
|
||||
Items []inner `json:"items"`
|
||||
}
|
||||
app := zip.New(zip.Config{AppName: "n", DisableStartupMessage: true})
|
||||
zip.Post(app, "/v1/outer", func(_ context.Context, in *outer) (*outer, error) { return in, nil },
|
||||
zip.WithOperationID("n_outer"))
|
||||
|
||||
sdk, err := app.SDK("n")
|
||||
if err != nil {
|
||||
t.Fatalf("SDK: %v", err)
|
||||
}
|
||||
if sdk.Ops() != 0 {
|
||||
t.Errorf("an op whose payload cannot cross got %d method(s):\n%s", sdk.Ops(), sdk.Source)
|
||||
}
|
||||
if strings.Contains(string(sdk.Source), "[]struct{}") {
|
||||
t.Errorf("a field that cannot cross was written as an empty struct:\n%s", sdk.Source)
|
||||
}
|
||||
if len(sdk.Gaps) == 0 {
|
||||
t.Fatal("no gap reported")
|
||||
}
|
||||
}
|
||||
|
||||
// Two ops sharing one refused type are two ops with no method, so they are two
|
||||
// gaps. The memo that remembers the refusal used to answer "no" and say
|
||||
// nothing, so the second op vanished from the package with nothing recorded and
|
||||
// the only way to notice was to count the methods. node's platformvm had
|
||||
// get_tx_rewards disappear exactly this way.
|
||||
func TestSDK_EveryOpRefusedIsAnOpReported(t *testing.T) {
|
||||
type shared struct {
|
||||
ID [32]byte `json:"id"`
|
||||
}
|
||||
app := zip.New(zip.Config{AppName: "s", DisableStartupMessage: true})
|
||||
zip.Get(app, "/v1/one", func(_ context.Context, in *shared) (*shared, error) { return in, nil },
|
||||
zip.WithOperationID("s_one"))
|
||||
zip.Get(app, "/v1/two", func(_ context.Context, in *shared) (*shared, error) { return in, nil },
|
||||
zip.WithOperationID("s_two"))
|
||||
|
||||
sdk, err := app.SDK("s")
|
||||
if err != nil {
|
||||
t.Fatalf("SDK: %v", err)
|
||||
}
|
||||
if sdk.Ops() != 0 {
|
||||
t.Fatalf("got %d method(s), want none:\n%s", sdk.Ops(), sdk.Source)
|
||||
}
|
||||
named := map[string]bool{}
|
||||
for _, g := range sdk.Gaps {
|
||||
named[g.Op] = true
|
||||
}
|
||||
for _, op := range []string{"s_one", "s_two"} {
|
||||
if !named[op] {
|
||||
t.Errorf("%s has no method and no gap; gaps are %+v", op, sdk.Gaps)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user