mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
App, Group, Operation. A group is a prefix and what holds under it, an operation
is one typed route declared there, and the verb methods are generic methods —
which Go 1.27 allows on a concrete type and still refuses on an interface. So In
and Out come from the handler and the caller writes no type arguments:
users := app.Group("/v1/users")
users.Post("/", api.Create)
mint := users.Group("/mint").With(middleware.Mint(gate))
mint.Post("/deposit", api.Deposit)
A Group is a CLOSED HANDLE: two unexported fields, and no method that hands back
the app, the parent or the fiber underneath. A subsystem given the group at its
own prefix cannot reach above it, so confinement is a property of the type
instead of a rule something has to police — which is what a host needs in order
to hand out a bounded surface and know what comes back.
That is bought by giving up the router interface, deliberately. Router and
OpTarget are gone, and with them the ability to implement a route table outside
this package: a decorator is no longer a router, a gate is middleware on a group,
and a bound is a path. The five package-level verb functions go the same way —
a registration is a method on the thing it registers into.
Raw carries what is not a typed operation: a stream, an upgrade, a probe, a
foreign subtree. It names its method, and it is the longer spelling because
opting out of the typed model is the exception and should read like one.
Co-authored-by: Hanzo Dev <[email protected]>
626 lines
21 KiB
Go
626 lines
21 KiB
Go
package zip_test
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// One service, registered the ordinary way. Everything the generated package
|
|
// contains is read back off these declarations — nothing about the SDK is
|
|
// written down anywhere, which is the property under test.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Height is a chain's last accepted block height.
|
|
type Height struct {
|
|
// Height is the height itself.
|
|
Height uint64 `json:"height"`
|
|
}
|
|
|
|
// ValidatorsIn asks for one network's validators.
|
|
type ValidatorsIn struct {
|
|
// NetID names the network whose validators are wanted.
|
|
NetID string `json:"netID" validate:"required"`
|
|
// NodeIDs narrows the answer to these nodes.
|
|
NodeIDs []string `json:"nodeIDs"`
|
|
}
|
|
|
|
type Validator struct {
|
|
NodeID string `json:"nodeID"`
|
|
Weight uint64 `json:"weight"`
|
|
Fee int32 `json:"fee"`
|
|
}
|
|
|
|
type Validators struct {
|
|
Validators []Validator `json:"validators"`
|
|
}
|
|
|
|
func sdkGetHeight(context.Context, *struct{}) (*Height, error) {
|
|
return &Height{Height: 0xDEADBEEFCAFE}, nil
|
|
}
|
|
|
|
// sdkGetValidators echoes its whole input back through the reply, so a field
|
|
// read from the wrong offset lands visibly rather than as a plausible zero.
|
|
func sdkGetValidators(_ context.Context, in *ValidatorsIn) (*Validators, error) {
|
|
out := &Validators{}
|
|
for i, n := range in.NodeIDs {
|
|
out.Validators = append(out.Validators, Validator{
|
|
NodeID: in.NetID + "/" + n,
|
|
Weight: uint64(len(n)),
|
|
Fee: int32(-1 - i),
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func sdkApp() *zip.App {
|
|
app := zip.New(zip.Config{AppName: "platform", DisableStartupMessage: true})
|
|
app.Get("/v1/platform/height", sdkGetHeight)
|
|
app.Post("/v1/platform/validators", sdkGetValidators)
|
|
return app
|
|
}
|
|
|
|
// TestSDK_CallsTheLiveServiceOverZAP is the headline: a Go package that never
|
|
// saw the service's types, generated from its document alone, calls it over ZAP
|
|
// and gets the exact values back — including a nested list of structs and a
|
|
// signed field beside an unsigned one, where a mistaken width would move every
|
|
// field after it.
|
|
//
|
|
// It compiles the generated package for real, because "it renders plausible Go"
|
|
// is not the claim. The claim is that it CALLS.
|
|
func TestSDK_CallsTheLiveServiceOverZAP(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("compiles a package with the go toolchain")
|
|
}
|
|
sdk, err := sdkApp().SDK("platform")
|
|
if err != nil {
|
|
t.Fatalf("SDK: %v", err)
|
|
}
|
|
if len(sdk.Gaps) != 0 {
|
|
t.Fatalf("gaps on a document with none: %+v", sdk.Gaps)
|
|
}
|
|
|
|
// The probe is built OFFLINE against this checkout: zip's own go.sum
|
|
// resolves its dependency graph from the module cache, so the test needs no
|
|
// network and no `go mod tidy`.
|
|
dir := t.TempDir()
|
|
zipDir, err := filepath.Abs(".")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sum, err := os.ReadFile("go.sum")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
goLine, err := os.ReadFile("go.mod")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
directive := "go 1.26.5"
|
|
for _, line := range strings.Split(string(goLine), "\n") {
|
|
if strings.HasPrefix(line, "go ") {
|
|
directive = strings.TrimSpace(line)
|
|
break
|
|
}
|
|
}
|
|
write := func(name string, body []byte) {
|
|
if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, name), body, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
write("go.mod", []byte("module sdkprobe\n\n"+directive+
|
|
"\n\nrequire github.com/zap-proto/zip v1.36.16\n\nreplace github.com/zap-proto/zip => "+zipDir+"\n"))
|
|
write("go.sum", sum)
|
|
write("platform/client.go", sdk.Source)
|
|
write("main.go", []byte(sdkProbeMain))
|
|
|
|
// The driver serves the SAME handlers over a unix socket and calls them
|
|
// through the generated client, so the two ends of the assertion are the
|
|
// declaration and the generated package, with the wire in between.
|
|
cmd := exec.Command("go", "run", ".")
|
|
cmd.Dir = dir
|
|
cmd.Env = append(os.Environ(), "GOFLAGS=-mod=mod", "GOPROXY=off")
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
if strings.Contains(string(out), "missing go.sum entry") || strings.Contains(string(out), "cannot find module") {
|
|
t.Skipf("module cache does not hold zip's dependencies: %s", out)
|
|
}
|
|
t.Fatalf("go run: %v\n%s", err, out)
|
|
}
|
|
if !strings.Contains(string(out), "ROUNDTRIP OK") {
|
|
t.Fatalf("generated client did not round-trip:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// sdkProbeMain drives the generated package against the live app.
|
|
const sdkProbeMain = `package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/zap-proto/zip"
|
|
"sdkprobe/platform"
|
|
)
|
|
|
|
type height struct {
|
|
Height uint64 ` + "`json:\"height\"`" + `
|
|
}
|
|
type validatorsIn struct {
|
|
NetID string ` + "`json:\"netID\" validate:\"required\"`" + `
|
|
NodeIDs []string ` + "`json:\"nodeIDs\"`" + `
|
|
}
|
|
type validator struct {
|
|
NodeID string ` + "`json:\"nodeID\"`" + `
|
|
Weight uint64 ` + "`json:\"weight\"`" + `
|
|
Fee int32 ` + "`json:\"fee\"`" + `
|
|
}
|
|
type validators struct {
|
|
Validators []validator ` + "`json:\"validators\"`" + `
|
|
}
|
|
|
|
func getHeight(context.Context, *struct{}) (*height, error) {
|
|
return &height{Height: 0xDEADBEEFCAFE}, nil
|
|
}
|
|
func getValidators(_ context.Context, in *validatorsIn) (*validators, error) {
|
|
out := &validators{}
|
|
for i, n := range in.NodeIDs {
|
|
out.Validators = append(out.Validators, validator{
|
|
NodeID: in.NetID + "/" + n, Weight: uint64(len(n)), Fee: int32(-1 - i),
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func fail(what string, args ...any) {
|
|
fmt.Fprintf(os.Stderr, what+"\n", args...)
|
|
os.Exit(1)
|
|
}
|
|
|
|
func main() {
|
|
sock := filepath.Join(os.TempDir(), fmt.Sprintf("sdkprobe-%d.sock", os.Getpid()))
|
|
defer os.Remove(sock)
|
|
|
|
app := zip.New(zip.Config{AppName: "platform", DisableStartupMessage: true})
|
|
app.Get("/v1/platform/height", getHeight)
|
|
app.Post("/v1/platform/validators", getValidators)
|
|
if _, err := zip.Serve(app, sock); err != nil {
|
|
fail("serve: %v", err)
|
|
}
|
|
time.Sleep(300 * time.Millisecond)
|
|
|
|
c, err := platform.Dial(sock)
|
|
if err != nil {
|
|
fail("dial: %v", err)
|
|
}
|
|
ctx := context.Background()
|
|
|
|
h, err := c.GetPlatformHeight(ctx)
|
|
if err != nil {
|
|
fail("GetPlatformHeight: %v", err)
|
|
}
|
|
if h.Height != 0xDEADBEEFCAFE {
|
|
fail("height: got %#x want 0xdeadbeefcafe", h.Height)
|
|
}
|
|
|
|
v, err := c.PostPlatformValidators(ctx, &platform.ValidatorsIn{
|
|
NetID: "8675309", NodeIDs: []string{"alpha", "bee"},
|
|
})
|
|
if err != nil {
|
|
fail("PostPlatformValidators: %v", err)
|
|
}
|
|
want := []platform.Validator{
|
|
{NodeID: "8675309/alpha", Weight: 5, Fee: -1},
|
|
{NodeID: "8675309/bee", Weight: 3, Fee: -2},
|
|
}
|
|
if len(v.Validators) != len(want) {
|
|
fail("validators: got %d want %d (%+v)", len(v.Validators), len(want), v.Validators)
|
|
}
|
|
for i := range want {
|
|
if v.Validators[i] != want[i] {
|
|
fail("validator %d: got %+v want %+v", i, v.Validators[i], want[i])
|
|
}
|
|
}
|
|
fmt.Println("ROUNDTRIP OK")
|
|
}
|
|
`
|
|
|
|
// TestSDK_TheNameIsTheOpsName is what makes the whole exercise worth doing: a
|
|
// hand-written client sends a method string somebody typed, and a typo compiles.
|
|
// Here every call site is the operationId the registry published, so the class
|
|
// of defect where a client addresses a name the service does not serve cannot
|
|
// be written down.
|
|
func TestSDK_TheNameIsTheOpsName(t *testing.T) {
|
|
app := sdkApp()
|
|
sdk, err := app.SDK("platform")
|
|
if err != nil {
|
|
t.Fatalf("SDK: %v", err)
|
|
}
|
|
src := string(sdk.Source)
|
|
|
|
ids := map[string]bool{}
|
|
for _, c := range app.Commands() {
|
|
ids[c.OperationID] = true
|
|
}
|
|
if len(ids) == 0 {
|
|
t.Fatal("no ops registered")
|
|
}
|
|
for id := range ids {
|
|
if !strings.Contains(src, `"`+id+`"`) {
|
|
t.Errorf("op %q has no call site in the generated client", id)
|
|
}
|
|
}
|
|
// And nothing else is addressed. Every quoted argument to Call must be an
|
|
// id the service actually published.
|
|
for _, line := range strings.Split(src, "\n") {
|
|
i := strings.Index(line, "c.conn, \"")
|
|
if i < 0 {
|
|
continue
|
|
}
|
|
rest := line[i+len("c.conn, \""):]
|
|
called := rest[:strings.IndexByte(rest, '"')]
|
|
if !ids[called] {
|
|
t.Errorf("generated client calls %q, which no op declares", called)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSDK_IsDeterministic pins that regenerating an unchanged registry produces
|
|
// an unchanged file. A generator whose output depends on map order makes every
|
|
// regeneration a diff, and a diff nobody can read is a diff nobody reviews.
|
|
func TestSDK_IsDeterministic(t *testing.T) {
|
|
// Two apps: one whose types cross by derivation, one that states its wire.
|
|
// The second walks maps the first does not — which types are coded, and
|
|
// which imports their codecs reach for — and Go randomises map order per
|
|
// run. A generator that wobbled would fail the regenerate-and-diff gate
|
|
// downstream on a change nobody made.
|
|
coded := func() *zip.App {
|
|
app := zip.New(zip.Config{AppName: "p", DisableStartupMessage: true})
|
|
app.Post("/v1/tx", func(_ context.Context, in *Tx) (*Tx, error) { return in, nil }, zip.WithOperationID("get_tx"))
|
|
app.Post("/v1/ided", func(_ context.Context, in *Ided) (*Ided, error) { return in, nil }, zip.WithOperationID("get_ided"))
|
|
return app
|
|
}
|
|
for _, build := range []struct {
|
|
name string
|
|
app func() *zip.App
|
|
pkg string
|
|
}{
|
|
{"derived", sdkApp, "platform"},
|
|
{"stated", coded, "p"},
|
|
} {
|
|
first, err := build.app().SDK(build.pkg)
|
|
if err != nil {
|
|
t.Fatalf("%s: SDK: %v", build.name, err)
|
|
}
|
|
for i := 0; i < 8; i++ {
|
|
again, err := build.app().SDK(build.pkg)
|
|
if err != nil {
|
|
t.Fatalf("%s: SDK: %v", build.name, err)
|
|
}
|
|
if string(again.Source) != string(first.Source) {
|
|
t.Fatalf("%s: run %d differs from run 0", build.name, i+1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSDK_TheLayoutIsTheSameLayout is the property the whole derivation rests
|
|
// on: the generated struct and the declared struct have byte-identical wire
|
|
// shapes. A field order, a skipped tag or a flattened embedding that diverged
|
|
// would not fail to compile — it would read every value after it from the wrong
|
|
// offset, which is what TestSDK_CallsTheLiveServiceOverZAP catches at run time
|
|
// and this catches at the derivation.
|
|
func TestSDK_TheLayoutIsTheSameLayout(t *testing.T) {
|
|
sdk, err := sdkApp().SDK("platform")
|
|
if err != nil {
|
|
t.Fatalf("SDK: %v", err)
|
|
}
|
|
src := string(sdk.Source)
|
|
// Declaration order, verbatim from the Go type — not the document's
|
|
// alphabetical property list, where Fee would come first.
|
|
want := "type Validator struct {\n\tNodeID string `json:\"nodeID\"`\n\tWeight uint64 `json:\"weight\"`\n\tFee int32 `json:\"fee\"`\n}"
|
|
got := strings.Join(strings.Fields(src), " ")
|
|
if !strings.Contains(got, strings.Join(strings.Fields(want), " ")) {
|
|
t.Fatalf("Validator is not declared field-for-field in order:\n%s", src)
|
|
}
|
|
}
|
|
|
|
// TestSDK_WidthsAreExact is why the derivation reads the Go type. Over ZAP a
|
|
// field is an offset AND a width, so a generated uint32 read as int64 does not
|
|
// merely mis-print: it takes eight bytes where the service laid four, and every
|
|
// field after it is read from the wrong place.
|
|
func TestSDK_WidthsAreExact(t *testing.T) {
|
|
type widths struct {
|
|
A uint8 `json:"a"`
|
|
B int16 `json:"b"`
|
|
C uint32 `json:"c"`
|
|
D int64 `json:"d"`
|
|
E float32 `json:"e"`
|
|
F float64 `json:"f"`
|
|
}
|
|
app := zip.New(zip.Config{AppName: "w", DisableStartupMessage: true})
|
|
app.Post("/v1/w", func(_ context.Context, in *widths) (*widths, error) { return in, nil })
|
|
|
|
sdk, err := app.SDK("w")
|
|
if err != nil {
|
|
t.Fatalf("SDK: %v", err)
|
|
}
|
|
// gofmt aligns a struct's columns, so the assertion reads the fields with
|
|
// their runs of spaces collapsed rather than pinning the alignment.
|
|
src := strings.Join(strings.Fields(string(sdk.Source)), " ")
|
|
for _, want := range []string{
|
|
"A uint8 `json:\"a\"`",
|
|
"B int16 `json:\"b\"`",
|
|
"C uint32 `json:\"c\"`",
|
|
"D int64 `json:\"d\"`",
|
|
"E float32 `json:\"e\"`",
|
|
"F float64 `json:\"f\"`",
|
|
} {
|
|
if !strings.Contains(src, want) {
|
|
t.Errorf("generated package does not declare %s\n--- source ---\n%s", want, sdk.Source)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSDK_GapsAreNotSilent is the same rule [Schema.Gaps] follows: a value the
|
|
// wire cannot carry is REPORTED, never quietly dropped. An op whose input holds
|
|
// one gets no method at all — a client that could be called and could never
|
|
// succeed is worse than one that says the operation is out of reach.
|
|
func TestSDK_GapsAreNotSilent(t *testing.T) {
|
|
type ledger struct {
|
|
Fine string `json:"fine"`
|
|
Balance map[string]int `json:"balance"`
|
|
}
|
|
type loose struct {
|
|
Fine string `json:"fine"`
|
|
Extra any `json:"extra"`
|
|
}
|
|
for _, tc := range []struct {
|
|
name string
|
|
reg func(*zip.App)
|
|
cause string
|
|
}{
|
|
{"map", func(a *zip.App) {
|
|
a.Post("/v1/a", func(_ context.Context, in *ledger) (*ledger, error) { return in, nil })
|
|
}, zip.CauseMap},
|
|
{"any", func(a *zip.App) {
|
|
a.Post("/v1/a", func(_ context.Context, in *loose) (*loose, error) { return in, nil })
|
|
}, zip.CauseAny},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
app := zip.New(zip.Config{AppName: "a", DisableStartupMessage: true})
|
|
tc.reg(app)
|
|
sdk, err := app.SDK("a")
|
|
if err != nil {
|
|
t.Fatalf("SDK: %v", err)
|
|
}
|
|
if sdk.Ops() != 0 {
|
|
t.Errorf("an op the wire refuses got %d method(s):\n%s", sdk.Ops(), sdk.Source)
|
|
}
|
|
if len(sdk.Gaps) == 0 {
|
|
t.Fatal("no gap reported")
|
|
}
|
|
for _, g := range sdk.Gaps {
|
|
if g.Cause == tc.cause {
|
|
return
|
|
}
|
|
}
|
|
t.Errorf("gaps do not name %q: %+v", tc.cause, sdk.Gaps)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSDK_AWholeAppStillGeneratesAroundAGap pins that one unreachable op does
|
|
// not cost the others their methods. The refusal is per operation, because that
|
|
// is the granularity at which it is true.
|
|
func TestSDK_AWholeAppStillGeneratesAroundAGap(t *testing.T) {
|
|
type ledger struct {
|
|
Balance map[string]int `json:"balance"`
|
|
}
|
|
app := sdkApp()
|
|
app.Post("/v1/platform/ledger", func(_ context.Context, in *ledger) (*ledger, error) { return in, nil })
|
|
|
|
sdk, err := app.SDK("platform")
|
|
if err != nil {
|
|
t.Fatalf("SDK: %v", err)
|
|
}
|
|
if sdk.Ops() != 2 {
|
|
t.Errorf("got %d methods, want the 2 reachable ops:\n%s", sdk.Ops(), sdk.Source)
|
|
}
|
|
if len(sdk.Gaps) != 1 {
|
|
t.Errorf("got %d gaps, want 1: %+v", len(sdk.Gaps), sdk.Gaps)
|
|
}
|
|
}
|
|
|
|
// TestSDK_AnIdNeedsItsCodecFirst is the gap that covers most of a chain node's
|
|
// API, so it is worth naming: ids.ID is [32]byte and ids.NodeID is [20]byte, and
|
|
// a fixed array is exactly the case where the LAYOUT is right and the reflective
|
|
// encoder still refuses — the type is expected to declare its own wire.
|
|
//
|
|
// Without this the generated client offers the method and the call fails on the
|
|
// wire, which is the failure this projection exists to remove. It is also not
|
|
// fixable by generating harder: the declared type's MarshalZAP lives in the
|
|
// service's package, and restating the field as [32]uint8 restates the bytes and
|
|
// not the layout.
|
|
func TestSDK_AnIdCrossesOnTheCodecTheSDKWrites(t *testing.T) {
|
|
type ID [32]byte
|
|
type Tx struct {
|
|
TxID ID `json:"txID"`
|
|
Height uint64 `json:"height"`
|
|
}
|
|
app := zip.New(zip.Config{AppName: "p", DisableStartupMessage: true})
|
|
app.Get("/v1/p/tx", func(context.Context, *struct{}) (*Tx, error) { return &Tx{}, nil })
|
|
|
|
sdk, err := app.SDK("p")
|
|
if err != nil {
|
|
t.Fatalf("SDK: %v", err)
|
|
}
|
|
if len(sdk.Gaps) != 0 {
|
|
t.Fatalf("an id is not a gap any more: %+v", sdk.Gaps)
|
|
}
|
|
if sdk.Ops() != 1 {
|
|
t.Fatalf("the op carrying an id got %d methods:\n%s", sdk.Ops(), sdk.Source)
|
|
}
|
|
src := string(sdk.Source)
|
|
// The offsets are the layout's, not a second derivation's: an id is 32
|
|
// bytes INLINE aligned to 1, so the u64 after it sits at 32 and not at 64.
|
|
for _, want := range []string{
|
|
"txTxIDAt = 0",
|
|
"txHeightAt = 32",
|
|
"txSize = 40",
|
|
"ob.SetBytesFixed(txTxIDAt, x.TxID[:])",
|
|
"copy(x.TxID[:], o.BytesFixed(txTxIDAt, 32))",
|
|
} {
|
|
if !strings.Contains(src, want) {
|
|
t.Errorf("the emitted codec does not carry the id inline: want %q\n%s", want, src)
|
|
}
|
|
}
|
|
// The ZAP schema still reports the field, and still should: [Schema.Coded]
|
|
// is what the REFLECTIVE encoder will not carry, which has not changed. It
|
|
// is the list of fields that need a codec, and now the SDK writes one.
|
|
if coded := zip.ZAPSchema("p", app).Coded; len(coded) != 1 || coded[0].Field != "TxID" {
|
|
t.Errorf("the schema stopped reporting the field reflection refuses: %+v", coded)
|
|
}
|
|
}
|
|
|
|
// TestSDK_OneOperationOneMethodName pins the naming rule across the spellings an
|
|
// operationId actually takes in the fleet, so a package regenerated after an id
|
|
// is respelled the same way twice is not a rename.
|
|
func TestSDK_OneOperationOneMethodName(t *testing.T) {
|
|
for _, tc := range []struct{ id, want string }{
|
|
{"get_platform_height", "GetPlatformHeight"},
|
|
{"platform.getHeight", "PlatformGetHeight"},
|
|
{"platform-get-height", "PlatformGetHeight"},
|
|
{"getHeight", "GetHeight"},
|
|
{"nodeID", "NodeID"},
|
|
} {
|
|
app := zip.New(zip.Config{AppName: "n", DisableStartupMessage: true})
|
|
app.Get("/v1/n", sdkGetHeight, zip.WithOperationID(tc.id))
|
|
sdk, err := app.SDK("n")
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", tc.id, err)
|
|
}
|
|
if !strings.Contains(string(sdk.Source), "func (c *Client) "+tc.want+"(") {
|
|
t.Errorf("%q rendered no method %s\n%s", tc.id, tc.want, sdk.Source)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// The refusal has to come from a value the wire genuinely cannot carry. An
|
|
// id is no longer one — a fixed array states its own wire now (see
|
|
// [TestSDK_AnIdCrossesOnTheCodecTheSDKWrites]) — so the field here is an
|
|
// interface, which names no type and so has nothing to declare. That is
|
|
// what node's two remaining gaps are.
|
|
type AssetID struct {
|
|
ID any `json:"id"`
|
|
}
|
|
type asset struct {
|
|
AssetID `json:"assetID"`
|
|
Name string `json:"name"`
|
|
}
|
|
app := zip.New(zip.Config{AppName: "x", DisableStartupMessage: true})
|
|
app.Get("/v1/asset", func(_ context.Context, in *asset) (*asset, error) { return in, nil }, zip.WithOperationID("x_asset"))
|
|
app.Get("/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 any `json:"id"`
|
|
}
|
|
type outer struct {
|
|
Name string `json:"name"`
|
|
Items []inner `json:"items"`
|
|
}
|
|
app := zip.New(zip.Config{AppName: "n", DisableStartupMessage: true})
|
|
app.Post("/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 any `json:"id"`
|
|
}
|
|
app := zip.New(zip.Config{AppName: "s", DisableStartupMessage: true})
|
|
app.Get("/v1/one", func(_ context.Context, in *shared) (*shared, error) { return in, nil }, zip.WithOperationID("s_one"))
|
|
app.Get("/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)
|
|
}
|
|
}
|
|
}
|